From da61fc8c4f4053e1d066a26525ce521bf2b5e491 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:39:27 +0100 Subject: [PATCH 01/66] feat(dashboard): add MAS visual debugger activity stack Derive run activity from existing dashboard state and mutations so MAS runs can be replayed as a full graph plus overlapping activity stack without backend DTO changes. Made-with: Cursor --- .../mas-run-visual-debugger/00-program.md | 110 ++ .../01-contracts-and-state.md | 232 ++++ .../02-frontend-implementation.md | 321 +++++ .../03-tests-and-e2e.md | 275 +++++ .../mas-run-visual-debugger/04-phases.md | 230 ++++ .../05-implementation-shape.md | 302 +++++ .../06-fast-feedback-and-visual-review.md | 309 +++++ .../plans/mas-run-visual-debugger/README.md | 22 + ergon-dashboard/.gitignore | 1 + .../mockups/mas-activity-stack-debugger.html | 780 ++++++++++++ .../mockups/mas-concurrency-debugger.html | 1092 +++++++++++++++++ .../mockups/mas-sequence-debugger.html | 803 ++++++++++++ .../app/api/runs/[runId]/mutations/route.ts | 8 + .../src/components/dag/DAGCanvas.tsx | 6 +- .../src/components/dag/TaskNode.tsx | 1 + .../src/components/run/RunWorkspacePage.tsx | 91 +- .../components/workspace/TaskWorkspace.tsx | 56 +- .../filterTaskEvidenceForTime.test.ts | 42 + .../workspace/filterTaskEvidenceForTime.ts | 76 ++ .../activity/buildRunActivities.test.ts | 44 + .../features/activity/buildRunActivities.ts | 268 ++++ .../activity/components/ActivityBar.tsx | 104 ++ .../components/ActivityStackTimeline.tsx | 239 ++++ .../features/activity/goldenFixture.test.ts | 59 + .../src/features/activity/stackLayout.test.ts | 66 + .../src/features/activity/stackLayout.ts | 99 ++ .../src/features/activity/types.ts | 39 + .../graph/components/ContainerNode.tsx | 3 + .../graph/layout/goldenLayout.test.ts | 95 ++ .../graph/layout/hierarchicalLayout.ts | 14 +- .../src/lib/testing/dashboardHarness.ts | 10 + ergon-dashboard/tests/e2e/_shared/smoke.ts | 24 + .../tests/e2e/activity-stack.spec.ts | 108 ++ .../tests/e2e/run.snapshot.spec.ts | 2 +- .../tests/fixtures/mas-runs/README.md | 5 + .../fixtures/mas-runs/concurrent-mas-run.json | 573 +++++++++ .../tests/helpers/dashboardFixtures.ts | 96 +- 37 files changed, 6573 insertions(+), 32 deletions(-) create mode 100644 docs/superpowers/plans/mas-run-visual-debugger/00-program.md create mode 100644 docs/superpowers/plans/mas-run-visual-debugger/01-contracts-and-state.md create mode 100644 docs/superpowers/plans/mas-run-visual-debugger/02-frontend-implementation.md create mode 100644 docs/superpowers/plans/mas-run-visual-debugger/03-tests-and-e2e.md create mode 100644 docs/superpowers/plans/mas-run-visual-debugger/04-phases.md create mode 100644 docs/superpowers/plans/mas-run-visual-debugger/05-implementation-shape.md create mode 100644 docs/superpowers/plans/mas-run-visual-debugger/06-fast-feedback-and-visual-review.md create mode 100644 docs/superpowers/plans/mas-run-visual-debugger/README.md create mode 100644 ergon-dashboard/mockups/mas-activity-stack-debugger.html create mode 100644 ergon-dashboard/mockups/mas-concurrency-debugger.html create mode 100644 ergon-dashboard/mockups/mas-sequence-debugger.html create mode 100644 ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts create mode 100644 ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.ts create mode 100644 ergon-dashboard/src/features/activity/buildRunActivities.test.ts create mode 100644 ergon-dashboard/src/features/activity/buildRunActivities.ts create mode 100644 ergon-dashboard/src/features/activity/components/ActivityBar.tsx create mode 100644 ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx create mode 100644 ergon-dashboard/src/features/activity/goldenFixture.test.ts create mode 100644 ergon-dashboard/src/features/activity/stackLayout.test.ts create mode 100644 ergon-dashboard/src/features/activity/stackLayout.ts create mode 100644 ergon-dashboard/src/features/activity/types.ts create mode 100644 ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts create mode 100644 ergon-dashboard/tests/e2e/activity-stack.spec.ts create mode 100644 ergon-dashboard/tests/fixtures/mas-runs/README.md create mode 100644 ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json diff --git a/docs/superpowers/plans/mas-run-visual-debugger/00-program.md b/docs/superpowers/plans/mas-run-visual-debugger/00-program.md new file mode 100644 index 00000000..8c25fe0f --- /dev/null +++ b/docs/superpowers/plans/mas-run-visual-debugger/00-program.md @@ -0,0 +1,110 @@ +# MAS Run Visual Debugger Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the current unreadable MAS run view with a visual debugger that shows the whole recursive graph at selected time `T`, an overlap-based bottom activity stack, and a task-scoped workspace drawer. + +**Architecture:** Keep graph mutation replay as the source of topology truth and derive the activity stack from existing run state plus graph mutations. Avoid fixed agent lanes: agents can join/leave dynamically, while tasks, events, and wall-clock timestamps are stable. Introduce small frontend domain modules for activity derivation/layout and keep backend DTO changes additive and narrow. + +**Tech Stack:** Next.js/React, TypeScript, React Flow, Tailwind CSS, Zod contracts, Playwright e2e, FastAPI test harness DTOs. + +--- + +## 1. Goals and non-goals + +**Goals** + +- Render the full recursive task graph as it existed at selected sequence/time `T`. +- Move the timeline into a bottom dock that visualizes concurrency by stacking overlapping activity bars. +- Keep the right-hand workspace task-scoped and time-aware: selecting a node at `T` shows resources, executions, messages, context events, and evaluations available at `T`. +- Make activity layout independent of agent cardinality. Agent/worker names are labels and filter metadata only. +- Preserve the live mode. Timeline mode must be opt-in and must not make the live dashboard feel stale. +- Add focused Playwright coverage that proves the graph canvas, activity stack, sequence scrubber, and workspace drawer are all usable on canonical MAS smoke runs. +- Use the mockup `ergon-dashboard/mockups/mas-activity-stack-debugger.html` as the UX target, not as code to copy directly. + +**Non-goals** + +- No rewrite of backend execution/control flow. +- No persistent "agent timeline" DTO. +- No new graph database model. +- No attempt to solve arbitrary huge-graph navigation in the first PR. The first PR should make the existing 9-leaf smoke and representative MAS samples readable. +- No replacing React Flow. +- No pixel-perfect visual snapshot testing in phase 1. Screenshot artifacts are review aids; assertions target stable structure and visibility. + +--- + +## 2. UX invariants + +- **Whole graph at T:** timeline scrub changes graph state, not graph scope. Collapsed containers are allowed for readability, but nodes are not silently omitted due to focus. +- **Concurrency by overlap:** overlapping work appears stacked vertically in the bottom dock. Vertical position means "needed another row because time overlaps", not "agent N". +- **Stable categories, unstable actors:** kind chips (`Execution`, `Graph`, `Talk`, `Artifact`, `Evaluation`, `Context`) are stable; worker/agent labels are secondary. +- **Task identity everywhere:** clicking an activity with `taskId` selects the graph node and opens the workspace. Clicking a graph node highlights related activity. +- **Replay is deterministic:** the same snapshot + mutation list + selected sequence produces the same graph and activity view. +- **Missing duration is explicit:** instant events render as markers; spans render as bars. Do not fake long durations for resources/messages/evaluations. + +--- + +## 3. DTO stance + +Production DTO changes should be avoided in the first phase unless implementation proves a real gap. + +Existing production data already gives the frontend enough to build the first activity stack: + +- `RunSnapshotDto` -> tasks, executions, resources, sandboxes, threads, evaluations. +- `dashboard/graph.mutation` + `/api/runs/{runId}/mutations` -> sequence, mutation kind, actor, reason, `created_at`. +- `context.event` state -> task execution, task node, event type, created/started/completed times where available. +- `task_evaluation_updated` -> task-scoped evaluation marker. + +Additive DTO work is still planned for testability and future precision: + +- Extend the **test harness** run-state DTO with activity-stack facts that Playwright can assert without reverse-engineering layout from pixels: mutation count, execution spans, context-event count, evaluation task IDs, and graph node IDs already exist; add `activity_event_count`, `activity_span_count`, and `max_concurrency` in Phase C if needed. +- Add production REST fields only if the current generated `RunSnapshotDto` lacks a timestamp needed for an honest bar. The likely candidate is evaluation duration (`startedAt`/`completedAt`) if evaluations become spans rather than instant markers. + +--- + +## 4. File map + +**New frontend domain files** + +- `ergon-dashboard/src/features/activity/types.ts` — `RunActivity`, `ActivityKind`, `ActivityStackRow`, layout result types. +- `ergon-dashboard/src/features/activity/buildRunActivities.ts` — pure derivation from `WorkflowRunState`, `RunEvent[]`, and `GraphMutationDto[]`. +- `ergon-dashboard/src/features/activity/stackLayout.ts` — overlap packing algorithm. +- `ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx` — bottom dock UI. +- `ergon-dashboard/src/features/activity/components/ActivityBar.tsx` — single bar/marker renderer. + +**Modified frontend files** + +- `ergon-dashboard/src/components/run/RunWorkspacePage.tsx` — three-pane debugger shell, timeline mode wiring, selection/highlight coordination. +- `ergon-dashboard/src/components/dag/DAGCanvas.tsx` — accept highlight props and expose stable graph container/node test IDs. +- `ergon-dashboard/src/features/graph/components/MutationTimeline.tsx` — either retire after Phase B or reduce to sequence controls reused by `ActivityStackTimeline`. +- `ergon-dashboard/src/components/workspace/TaskWorkspace.tsx` — filter task-scoped collections to selected sequence/time when timeline mode is active. +- `ergon-dashboard/src/lib/runEvents.ts` — keep flat event stream derivation, but do not make it own activity packing. + +**Modified tests** + +- `ergon-dashboard/tests/helpers/dashboardFixtures.ts` — add a concurrent MAS fixture with overlapping executions/context events. +- `ergon-dashboard/tests/e2e/_shared/smoke.ts` — assert activity stack presence and screenshots. +- `ergon-dashboard/tests/helpers/backendHarnessClient.ts` — add narrow test harness fields only if backend exposes them. + +--- + +## 5. Merge checklist + +- [ ] `pnpm --dir ergon-dashboard test` or the repository's frontend unit command is green for activity derivation/layout tests. +- [ ] `pnpm --dir ergon-dashboard run check` or current frontend type/lint command is green. +- [ ] Playwright smoke opens a canonical MAS run, enters timeline mode, sees `activity-stack-region`, scrubs sequence, opens workspace from a graph node, and captures run screenshots. +- [ ] Activity stack never creates rows from agent names. +- [ ] E2E screenshot shows full recursive graph at selected `T`, not focus-filtered branch-only graph. +- [ ] Implementation handoff includes PNGs of every new UI panel: full debugger page, graph canvas, activity stack bottom dock, and workspace drawer open on a selected task. +- [ ] Existing live run updates still render without requiring mutation fetch success. +- [ ] No production backend DTO changes unless justified in `01-contracts-and-state.md`. + +--- + +## 6. Open decisions + +1. **Activity source for graph mutations:** default to using `/api/runs/{runId}/mutations` in timeline mode. If live mode needs graph mutation bars before entering timeline, also retain recent socket mutation events in `useRunState`. +2. **Evaluation duration:** default to instant marker at `evaluation.createdAt`. Upgrade to span only if backend has real start/end timestamps. +3. **Viewport fit:** default to React Flow `fitView` on initial load and sequence changes only when user has not manually panned/zoomed. +4. **Saved layout state:** defer persistence of pane sizes/zoom to a follow-up. +5. **Virtualization:** defer until the activity count in a smoke run or real run demonstrably causes UI lag. diff --git a/docs/superpowers/plans/mas-run-visual-debugger/01-contracts-and-state.md b/docs/superpowers/plans/mas-run-visual-debugger/01-contracts-and-state.md new file mode 100644 index 00000000..827393e5 --- /dev/null +++ b/docs/superpowers/plans/mas-run-visual-debugger/01-contracts-and-state.md @@ -0,0 +1,232 @@ +# 01 — Contracts and State + +**Status:** draft. +**Scope:** DTO inventory, frontend activity model, deterministic replay contract, and exact places where additive DTO changes may be needed. + +Cross-refs: program goals in [`00-program.md`](00-program.md), UI work in [`02-frontend-implementation.md`](02-frontend-implementation.md), e2e contract in [`03-tests-and-e2e.md`](03-tests-and-e2e.md). + +--- + +## 1. Existing contract inventory + +### Production snapshot/state + +- `ergon-dashboard/src/lib/contracts/rest.ts` + - `RunSnapshot` already includes tasks, executions, resources, sandboxes, threads, and evaluations via generated schemas. + - `RunExecutionAttempt` has `startedAt` and `completedAt`, which are true span endpoints. + - `RunSandbox` has `createdAt` and `closedAt`, which are true span endpoints. + - `RunSandboxCommand` has `timestamp` and `durationMs`, which can render as short command spans. + - `RunTaskEvaluation` currently behaves like an instant marker unless start/end timestamps are present in generated schema. + +- `ergon-dashboard/src/lib/types.ts` + - `WorkflowRunState` is the in-memory source for current display state. + - `TaskState.history` records task transitions with sequence/time/actor/reason. + +### Graph mutations + +- `ergon-dashboard/src/features/graph/contracts/graphMutations.ts` + - `GraphMutationDto` has `sequence`, `mutation_type`, `target_id`, `actor`, `reason`, `created_at`. + - This is sufficient for graph mutation markers and sequence scrubbing. + +- `ergon-dashboard/src/features/graph/state/graphMutationReducer.ts` + - `replayToSequence` is the topology/status replay engine. + - Activity derivation should consume its result; it should not duplicate graph replay. + +### Unified event stream + +- `ergon-dashboard/src/lib/runEvents.ts` + - `buildRunEvents()` already flattens workflow lifecycle, task transitions, sandbox events, messages, evaluations, resources, context events, and unhandled mutations. + - Keep this useful for event rows and activity markers, but implement span packing in a separate `features/activity` module. + +--- + +## 2. Frontend domain model + +Create `ergon-dashboard/src/features/activity/types.ts`. + +```typescript +import type { RunEventKind } from "@/lib/runEvents"; + +export type ActivityKind = + | "execution" + | "graph" + | "message" + | "artifact" + | "evaluation" + | "context" + | "sandbox"; + +export interface RunActivity { + id: string; + kind: ActivityKind; + label: string; + taskId: string | null; + sequence: number | null; + startAt: string; + endAt: string | null; + isInstant: boolean; + actor: string | null; + sourceKind: RunEventKind | "execution.span" | "sandbox.span" | "graph.mutation"; + metadata: Record; +} + +export interface ActivityStackItem { + activity: RunActivity; + row: number; + leftPct: number; + widthPct: number; +} + +export interface ActivityStackLayout { + items: ActivityStackItem[]; + rowCount: number; + startMs: number; + endMs: number; + maxConcurrency: number; +} +``` + +Rules: + +- `startAt` is always required. +- `endAt` is `null` for markers. +- `isInstant` is true when `endAt === null` or when duration is below the render minimum. +- `taskId` can be null for workflow-level events. +- `actor` is metadata only; it must not become a lane key. + +--- + +## 3. Activity derivation + +Create `ergon-dashboard/src/features/activity/buildRunActivities.ts`. + +Inputs: + +```typescript +import type { GraphMutationDto } from "@/features/graph/contracts/graphMutations"; +import type { RunEvent } from "@/lib/runEvents"; +import type { WorkflowRunState } from "@/lib/types"; +import type { RunActivity } from "./types"; + +export interface BuildRunActivitiesInput { + runState: WorkflowRunState | null; + events: RunEvent[]; + mutations: GraphMutationDto[]; + currentSequence: number | null; +} + +export function buildRunActivities(input: BuildRunActivitiesInput): RunActivity[] { + if (!input.runState) return []; + return [ + ...executionActivities(input.runState), + ...sandboxActivities(input.runState), + ...contextActivities(input.runState), + ...eventMarkerActivities(input.events), + ...graphMutationActivities(input.mutations), + ].sort(compareActivity); +} +``` + +Derivation rules: + +- Executions: one span per `ExecutionAttemptState` with non-null `startedAt`; use `completedAt` when available, otherwise render open span through selected/current time. +- Sandboxes: one span per `SandboxState`; use `closedAt` when available. +- Sandbox commands: marker or short span using `timestamp + durationMs`. +- Context events: span if both `startedAt` and `completedAt` exist; otherwise marker at `createdAt`. +- Thread messages, resources, evaluations, workflow lifecycle: marker activities from `RunEvent`. +- Graph mutations: marker activities from `GraphMutationDto`. +- Duplicate suppression: do not render both a `task.transition` event and a `graph.mutation` marker as identical labels if they share the same sequence/task/status. Prefer the graph mutation marker for sequence navigation and keep task transition in the event stream. + +--- + +## 4. Stack layout + +Create `ergon-dashboard/src/features/activity/stackLayout.ts`. + +```typescript +import type { ActivityStackLayout, RunActivity } from "./types"; + +export interface StackActivityOptions { + minMarkerWidthPct: number; + minSpanWidthPct: number; +} + +export function stackActivities( + activities: RunActivity[], + options: StackActivityOptions = { minMarkerWidthPct: 0.35, minSpanWidthPct: 0.75 }, +): ActivityStackLayout { + const timed = activities + .map((activity) => toTimedActivity(activity)) + .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs || a.activity.id.localeCompare(b.activity.id)); + + if (timed.length === 0) { + return { items: [], rowCount: 0, startMs: 0, endMs: 0, maxConcurrency: 0 }; + } + + const startMs = Math.min(...timed.map((a) => a.startMs)); + const endMs = Math.max(...timed.map((a) => a.endMs)); + const spanMs = Math.max(1, endMs - startMs); + const rowEnds: number[] = []; + let maxConcurrency = 0; + + const items = timed.map(({ activity, startMs: itemStartMs, endMs: itemEndMs }) => { + const row = firstFreeRow(rowEnds, itemStartMs); + rowEnds[row] = itemEndMs; + maxConcurrency = Math.max(maxConcurrency, rowEnds.filter((rowEnd) => rowEnd > itemStartMs).length); + + const leftPct = ((itemStartMs - startMs) / spanMs) * 100; + const rawWidthPct = ((itemEndMs - itemStartMs) / spanMs) * 100; + const widthPct = activity.isInstant + ? options.minMarkerWidthPct + : Math.max(options.minSpanWidthPct, rawWidthPct); + + return { activity, row, leftPct, widthPct }; + }); + + return { items, rowCount: rowEnds.length, startMs, endMs, maxConcurrency }; +} +``` + +Acceptance rules: + +- Two overlapping spans must be placed on different rows. +- Adjacent non-overlapping spans can reuse the same row. +- Instant markers should not force every later item onto a new row; give them a small render interval only for collision. +- Layout must be deterministic for identical inputs. + +--- + +## 5. DTO change decision tree + +Use this decision tree before editing backend schema files: + +1. Can the UI derive the fact from `WorkflowRunState`, `RunEvent[]`, or `GraphMutationDto[]` without lying about time? If yes, do not change production DTOs. +2. Is the missing fact only needed by Playwright? If yes, add it to `ergon_core/core/api/test_harness.py` and `ergon-dashboard/tests/helpers/backendHarnessClient.ts`, not production REST. +3. Is the missing fact needed by users and already persisted? If yes, add it to the production API schema and generated frontend contracts. +4. Is the missing fact not persisted? Stop and design the backend persistence change separately; do not smuggle fake frontend fields into the UI. + +Likely first-PR DTO edits: + +- **Test harness only:** add `activity_event_count`, `activity_span_count`, `max_concurrency` after the frontend derivation is stable enough to calculate the same values in backend or harness queries. +- **No production DTO edit:** keep evaluations as markers unless persisted evaluation span timestamps already exist. + +--- + +## 6. Unit test checklist + +Create `ergon-dashboard/src/features/activity/buildRunActivities.test.ts`. + +- [ ] Execution with start/end becomes a span with `kind: "execution"`. +- [ ] Running execution with no end becomes open span using current selected time. +- [ ] Resource event becomes instant `kind: "artifact"` marker. +- [ ] Evaluation event becomes instant `kind: "evaluation"` marker. +- [ ] Graph mutation becomes instant `kind: "graph"` marker with sequence. +- [ ] Actor names appear in metadata but not in row assignment input. + +Create `ergon-dashboard/src/features/activity/stackLayout.test.ts`. + +- [ ] Non-overlapping spans reuse one row. +- [ ] Overlapping spans use two rows. +- [ ] Three-way overlap reports `maxConcurrency === 3`. +- [ ] Instant markers do not permanently block a row. +- [ ] Same input order-independent set produces identical `row` assignments after sorting. diff --git a/docs/superpowers/plans/mas-run-visual-debugger/02-frontend-implementation.md b/docs/superpowers/plans/mas-run-visual-debugger/02-frontend-implementation.md new file mode 100644 index 00000000..5c4e44b2 --- /dev/null +++ b/docs/superpowers/plans/mas-run-visual-debugger/02-frontend-implementation.md @@ -0,0 +1,321 @@ +# 02 — Frontend Implementation + +**Status:** draft. +**Scope:** component boundaries, UI behavior, and task-by-task implementation plan for the visual debugger shell. + +Cross-refs: contracts in [`01-contracts-and-state.md`](01-contracts-and-state.md), tests in [`03-tests-and-e2e.md`](03-tests-and-e2e.md), phase order in [`04-phases.md`](04-phases.md). + +--- + +## 1. Target layout + +The run page becomes a three-region visual debugger: + +- Header/status strip remains at the top with run status, cohort breadcrumb, live/timeline toggle, and connection state. +- Main region is the React Flow recursive graph, showing the whole graph at selected `T`. +- Bottom dock is `ActivityStackTimeline`, always horizontal time, vertical rows allocated by overlap. +- Right drawer is `TaskWorkspace`, opened by graph node or activity click. + +The accepted mockup is `ergon-dashboard/mockups/mas-activity-stack-debugger.html`. + +--- + +## 2. Component map + +### New components + +- `ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx` + - Props: activities, current sequence, selected task, selected activity, callbacks. + - Owns time ruler, row rendering, legend, scrubber controls, and empty state. + +- `ergon-dashboard/src/features/activity/components/ActivityBar.tsx` + - Props: stack item, selected/highlight booleans, click handler. + - Renders span or marker using kind-specific styling. + +### Modified components + +- `RunWorkspacePage.tsx` + - Replaces old bottom `MutationTimeline` region with activity stack. + - Creates `activities = buildRunActivities({ runState: displayState, events, mutations, currentSequence })`. + - Tracks `selectedActivityId`. + - Activity click sets current sequence if present and selects `taskId` if present. + - Graph node click selects task and highlights related activities. + +- `DAGCanvas.tsx` + - Accepts `highlightedTaskIds?: Set`. + - Passes selected/highlight information through node data. + - Keeps depth expansion controls, search, minimap, and React Flow controls. + - Ensures canvas has `data-testid="graph-canvas"` and individual graph elements keep `graph-node-{taskId}` / `graph-container-{taskId}`. + +- `TaskWorkspace.tsx` + - Accepts `selectedTime?: string | null` or `currentSequence?: number | null`. + - Filters task collections for timeline mode only: + - resources with `createdAt <= selectedTime` + - executions with `startedAt <= selectedTime` + - sandbox commands with `timestamp <= selectedTime` + - thread messages with `createdAt <= selectedTime` + - context events with `createdAt <= selectedTime` + - evaluation with `createdAt <= selectedTime` + - Live mode keeps current behavior. + +--- + +## 3. Task 1: Activity domain module + +**Files:** + +- Create: `ergon-dashboard/src/features/activity/types.ts` +- Create: `ergon-dashboard/src/features/activity/buildRunActivities.ts` +- Create: `ergon-dashboard/src/features/activity/stackLayout.ts` +- Test: `ergon-dashboard/src/features/activity/buildRunActivities.test.ts` +- Test: `ergon-dashboard/src/features/activity/stackLayout.test.ts` + +- [ ] **Step 1: Write tests for activity derivation** + +```typescript +import { describe, expect, it } from "vitest"; +import { buildRunActivities } from "./buildRunActivities"; + +describe("buildRunActivities", () => { + it("renders execution attempts as spans and graph mutations as sequence markers", () => { + const activities = buildRunActivities({ + runState: makeRunStateWithExecution({ + taskId: "task-a", + startedAt: "2026-04-26T10:00:00.000Z", + completedAt: "2026-04-26T10:00:05.000Z", + }), + events: [], + mutations: [ + makeGraphMutation({ + sequence: 12, + target_id: "task-a", + mutation_type: "node.status_changed", + created_at: "2026-04-26T10:00:01.000Z", + }), + ], + currentSequence: 12, + }); + + expect(activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "execution", taskId: "task-a", isInstant: false }), + expect.objectContaining({ kind: "graph", taskId: "task-a", sequence: 12, isInstant: true }), + ]), + ); + }); +}); +``` + +- [ ] **Step 2: Write tests for stack packing** + +```typescript +import { describe, expect, it } from "vitest"; +import { stackActivities } from "./stackLayout"; + +describe("stackActivities", () => { + it("puts overlapping spans on separate rows and reuses rows after overlap ends", () => { + const layout = stackActivities([ + activity("a", "2026-04-26T10:00:00.000Z", "2026-04-26T10:00:10.000Z"), + activity("b", "2026-04-26T10:00:05.000Z", "2026-04-26T10:00:12.000Z"), + activity("c", "2026-04-26T10:00:12.000Z", "2026-04-26T10:00:15.000Z"), + ]); + + expect(layout.rowCount).toBe(2); + expect(layout.maxConcurrency).toBe(2); + expect(layout.items.find((item) => item.activity.id === "c")?.row).toBe(0); + }); +}); +``` + +- [ ] **Step 3: Implement derivation and packing** + +Implement the interfaces and functions from [`01-contracts-and-state.md`](01-contracts-and-state.md). Keep the implementation pure and free of React. + +- [ ] **Step 4: Run unit tests** + +Run: `pnpm --dir ergon-dashboard test src/features/activity` + +Expected: activity tests pass; no browser required. + +--- + +## 4. Task 2: Activity stack UI + +**Files:** + +- Create: `ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx` +- Create: `ergon-dashboard/src/features/activity/components/ActivityBar.tsx` +- Modify: `ergon-dashboard/src/lib/statusTokens.ts` only if existing colors cannot cover activity kinds. + +- [ ] **Step 1: Add render contract** + +`ActivityStackTimeline` must expose: + +- `data-testid="activity-stack-region"` on the dock root. +- `data-testid="activity-stack-row"` per rendered row. +- `data-testid="activity-bar-{activity.id}"` per activity. +- `data-kind` and `data-task-id` on activity bars. +- `data-testid="activity-current-sequence"` for the visible selected sequence. + +- [ ] **Step 2: Implement timeline controls** + +Controls required in first pass: + +- Step back/forward by available graph mutation sequence. +- Play/pause using mutation timestamps, preserving current `MutationTimeline` min/max delay behavior. +- Drag/scrub range input using sequence numbers. +- Kind legend showing counts. + +- [ ] **Step 3: Implement click behavior** + +Activity click behavior: + +```typescript +function handleActivityClick(activity: RunActivity) { + setSelectedActivityId(activity.id); + if (activity.sequence !== null) setCurrentSequence(activity.sequence); + if (activity.taskId) setSelectedTaskId(activity.taskId); +} +``` + +- [ ] **Step 4: Add empty and partial-data states** + +Empty states: + +- No run state: "Run state is still loading." +- Run has no activities: "No activity has been recorded for this run yet." +- Timeline mode has no mutations: show activities from snapshot timestamps but disable sequence scrub. + +--- + +## 5. Task 3: Wire into `RunWorkspacePage` + +**Files:** + +- Modify: `ergon-dashboard/src/components/run/RunWorkspacePage.tsx` +- Modify: `ergon-dashboard/src/features/graph/components/MutationTimeline.tsx` only if extracting reusable controls. + +- [ ] **Step 1: Build activities from display state** + +Add: + +```typescript +const activities = useMemo( + () => + buildRunActivities({ + runState: displayState, + events, + mutations, + currentSequence: timelineMode === "timeline" ? currentSequence : null, + }), + [displayState, events, mutations, timelineMode, currentSequence], +); +``` + +- [ ] **Step 2: Replace timeline region** + +Replace the old `MutationTimeline` bottom panel with: + +```tsx +
+ setIsPlaying((prev) => !prev)} + onSpeedChange={setPlaybackSpeed} + onActivityClick={handleActivityClick} + /> +
+``` + +- [ ] **Step 3: Preserve event stream** + +Keep `UnifiedEventStream` as a collapsible secondary inspector, not the primary bottom timeline. + +- [ ] **Step 4: Run frontend check** + +Run: `pnpm --dir ergon-dashboard run check` + +Expected: TypeScript and lint pass. + +--- + +## 6. Task 4: Time-aware workspace + +**Files:** + +- Modify: `ergon-dashboard/src/components/workspace/TaskWorkspace.tsx` +- Test: add or extend component/unit tests near existing workspace tests if present. + +- [ ] **Step 1: Add selected time prop** + +`RunWorkspacePage` computes: + +```typescript +const selectedTimelineTime = useMemo(() => { + if (timelineMode !== "timeline") return null; + return mutations.find((mutation) => mutation.sequence === currentSequence)?.created_at ?? null; +}, [timelineMode, mutations, currentSequence]); +``` + +- [ ] **Step 2: Filter visible task evidence** + +Inside `TaskWorkspace`, apply filtering only when `selectedTimelineTime` is non-null. Use ISO string comparison after converting both sides to milliseconds with `Date.parse`. + +- [ ] **Step 3: Show time badge** + +Add a small badge in the workspace header: + +`Viewing evidence available at seq {currentSequence}` + +Only render in timeline mode. + +--- + +## 7. Task 5: Graph highlighting and readability + +**Files:** + +- Modify: `ergon-dashboard/src/components/dag/DAGCanvas.tsx` +- Modify: `ergon-dashboard/src/components/dag/TaskNode.tsx` +- Modify: `ergon-dashboard/src/features/graph/components/ContainerNode.tsx` +- Modify: `ergon-dashboard/src/features/graph/components/LeafNode.tsx` +- Modify: `ergon-dashboard/src/features/graph/layout/hierarchicalLayout.ts` only for collision/readability fixes. + +- [ ] **Step 1: Add highlight data** + +Pass node data flags: + +```typescript +isSelected: task.id === selectedTaskId, +isHighlighted: highlightedTaskIds.has(task.id), +``` + +- [ ] **Step 2: Keep whole graph at T** + +Do not filter nodes by selected activity/task. Highlight related nodes while preserving full topology. + +- [ ] **Step 3: Improve fit and spacing only where measured** + +If overlap persists in the 9-leaf smoke graph, tune `MIN_CONTAINER_WIDTH`, `CONTAINER_PADDING`, and dagre separation constants in `layoutTypes.ts` / `hierarchicalLayout.ts`. Do not introduce a second graph layout engine in this PR. + +--- + +## 8. Task 6: Remove or demote old mutation strip + +**Files:** + +- Modify or delete: `ergon-dashboard/src/features/graph/components/MutationTimeline.tsx` + +Decision after Task 2: + +- If controls are reused, rename to `SequenceControls.tsx`. +- If no code is reused, delete the component and update imports. + +Acceptance: the only bottom timeline users see is activity-stack based. diff --git a/docs/superpowers/plans/mas-run-visual-debugger/03-tests-and-e2e.md b/docs/superpowers/plans/mas-run-visual-debugger/03-tests-and-e2e.md new file mode 100644 index 00000000..1afc4ed5 --- /dev/null +++ b/docs/superpowers/plans/mas-run-visual-debugger/03-tests-and-e2e.md @@ -0,0 +1,275 @@ +# 03 — Tests and E2E + +**Status:** draft. +**Scope:** frontend unit tests, dashboard fixture tests, Playwright smoke assertions, screenshot capture points, and optional backend harness DTO additions. + +Cross-refs: test-refactor north star in `docs/superpowers/plans/test-refactor/03-dashboard-and-playwright.md`, implementation tasks in [`02-frontend-implementation.md`](02-frontend-implementation.md). + +--- + +## 1. Test strategy + +Use five layers: + +- **Pure unit tests:** prove activity derivation and stack packing without React or browser layout. +- **Golden fixture semantic tests:** pump realistic serialized MAS run data through replay, activity derivation, stack layout, and graph layout. +- **Coarse browser geometry checks:** assert catastrophic overlaps do not happen without pinning exact pixels. +- **Dashboard fixture e2e:** seed a deterministic concurrent run through dashboard harness routes and assert the visual debugger contract quickly. +- **Canonical smoke e2e:** run against real backend state and capture screenshots for graph + activity stack review. + +Do not assert pixel-perfect bar positions. Assert stable structure, counts, selected state, and task/sequence coordination. +Use local PNG dumps for human visual review while building; do not make PNG diffs a CI gate in the first PR. + +--- + +## 2. Unit tests + +### Activity derivation tests + +File: `ergon-dashboard/src/features/activity/buildRunActivities.test.ts` + +Required cases: + +- `ExecutionAttemptState.startedAt/completedAt` -> execution span. +- open running execution -> execution span ending at selected timeline time. +- resource event -> artifact marker. +- thread message -> message marker. +- task evaluation -> evaluation marker. +- context event with start/end -> context span. +- graph mutation -> graph marker with sequence. +- no agent lane key is emitted. + +### Stack layout tests + +File: `ergon-dashboard/src/features/activity/stackLayout.test.ts` + +Required cases: + +- non-overlap reuses row. +- overlap allocates rows. +- three-way overlap reports max concurrency. +- instant marker has minimum render width. +- deterministic order independent of input order. + +### Golden fixture semantic tests + +Files: + +- `ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json` +- `ergon-dashboard/src/features/activity/goldenFixture.test.ts` +- `ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts` +- `ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts` + +Required cases: + +- replaying fixture mutations to checkpoint sequence `T` yields the whole expected graph at `T`. +- graph layout for the fixture has no overlapping node/container boxes using coarse rectangle checks. +- activity stack reports expected max concurrency for overlapping executions. +- activity rows are not grouped by agent or worker identity. +- task evidence filtering hides resources/messages/evaluations created after selected time. + +Full details live in [`06-fast-feedback-and-visual-review.md`](06-fast-feedback-and-visual-review.md). + +--- + +## 3. Dashboard fixture update + +Modify `ergon-dashboard/tests/helpers/dashboardFixtures.ts`. + +Add a fixture run with: + +- root task plus at least 5 child tasks. +- two executions overlapping between `12:00:10` and `12:00:20`. +- one sandbox command inside an execution span. +- one thread message marker. +- one resource marker. +- one evaluation marker attached to a non-root task. +- graph mutations with sequences spanning node add/status events. + +Suggested helper shape: + +```typescript +export function concurrentMasRunState(): SerializedWorkflowRunState { + return serializedRunState({ + scenario: "concurrent-mas-debugger", + }); +} +``` + +If `serializedRunState` is not currently parameterized, extract current fixture setup into small helpers first. Keep old fixture behavior unchanged for existing specs. + +--- + +## 4. Playwright dashboard fixture spec + +Create `ergon-dashboard/tests/e2e/activity-stack.spec.ts`. + +Core assertions: + +```typescript +test("run visual debugger shows recursive graph, activity stack, and time-aware workspace", async ({ page }) => { + const client = new DashboardHarnessClient(page); + const { cohortId, runId } = await client.seedConcurrentMasRun(); + + await page.goto(`/cohorts/${cohortId}/runs/${runId}`); + + await expect(page.getByTestId("run-header")).toBeVisible(); + await expect(page.getByTestId("graph-canvas")).toBeVisible(); + await expect(page.getByTestId("activity-stack-region")).toBeVisible(); + await expect(page.getByTestId("activity-stack-row")).toHaveCountGreaterThan(1); + expect( + await overlappingPairsFor(page, '[data-testid^="graph-node-"]'), + ).toEqual([]); + + const firstExecution = page.locator('[data-testid^="activity-bar-"][data-kind="execution"]').first(); + await expect(firstExecution).toBeVisible(); + await firstExecution.click(); + + await expect(page.getByTestId("workspace-region")).toBeVisible(); + await expect(page.getByTestId("workspace-header")).toBeVisible(); + + await page.getByTestId("activity-step-forward").click(); + await expect(page.getByTestId("activity-current-sequence")).toContainText(/seq/i); +}); +``` + +If Playwright's matcher set lacks `toHaveCountGreaterThan`, replace with: + +```typescript +expect(await page.getByTestId("activity-stack-row").count()).toBeGreaterThan(1); +``` + +Add coarse geometry helpers in the spec or shared helper: + +```typescript +async function overlappingPairsFor(page: Page, selector: string): Promise<[number, number][]> { + const boxes = await page.locator(selector).evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }), + ); + return overlappingPairs(boxes, { tolerancePx: 2 }); +} +``` + +The overlap assertion is intentionally coarse. It catches the broken layout class we care about without becoming a pixel-perfect visual test. + +### Local-only PNG dumps + +The fixture spec should dump review screenshots only when explicitly requested: + +```bash +VISUAL_DEBUGGER_SCREENSHOTS=1 pnpm --dir ergon-dashboard exec playwright test tests/e2e/activity-stack.spec.ts --project=chromium +``` + +Output: + +```text +ergon-dashboard/tmp/visual-debugger/run-full.png +ergon-dashboard/tmp/visual-debugger/graph-canvas.png +ergon-dashboard/tmp/visual-debugger/activity-stack.png +ergon-dashboard/tmp/visual-debugger/workspace-open.png +``` + +These PNGs are for local human review while building. They should not run in CI and should not be committed. + +--- + +## 5. Canonical smoke e2e changes + +Modify `ergon-dashboard/tests/e2e/_shared/smoke.ts`. + +Add to `assertRunWorkspace` after `graph-canvas` assertion: + +```typescript +await expect(page.getByTestId("activity-stack-region")).toBeVisible(); + +const activityBars = page.locator('[data-testid^="activity-bar-"]'); +await expect(activityBars.first()).toBeVisible(); + +if (state.mutation_count > 0) { + await page.getByTestId("mode-timeline").click(); + await expect(page.getByTestId("timeline-region")).toBeVisible(); + await expect(page.getByTestId("activity-current-sequence")).toContainText(/seq/i); +} +``` + +Screenshot additions: + +- `/-visual-debugger-full.png` — full run page. +- `/-activity-stack.png` — bottom dock if Playwright can screenshot locator reliably. +- Keep existing happy/sad screenshots until the new ones prove stable. + +--- + +## 6. Optional backend harness DTO additions + +Only add these after frontend derivation is implemented and the e2e test needs backend truth for concurrency: + +Modify backend `/api/test/read/run/{run_id}/state` DTO to include: + +```json +{ + "activity_event_count": 37, + "activity_span_count": 12, + "max_concurrency": 4 +} +``` + +Modify `ergon-dashboard/tests/helpers/backendHarnessClient.ts`: + +```typescript +export interface BackendRunState { + activity_event_count?: number; + activity_span_count?: number; + max_concurrency?: number; +} +``` + +Rules: + +- These fields are optional in TypeScript while the backend branch catches up. +- Do not block the visual debugger UI on these fields. +- If added, Playwright may assert `max_concurrency >= 2` for the smoke run. + +--- + +## 7. Accessibility and stable selectors + +Required test IDs: + +- `activity-stack-region` +- `activity-stack-row` +- `activity-bar-{activityId}` +- `activity-current-sequence` +- `activity-step-back` +- `activity-step-forward` +- `activity-play-toggle` +- `activity-speed-control` +- `graph-canvas` +- `graph-node-{taskId}` +- `graph-container-{taskId}` +- `workspace-region` +- `workspace-header` + +Required ARIA labels: + +- Activity bar button: `Open activity {label}`. +- Sequence scrubber: `Run timeline sequence`. +- Play/pause: `Play timeline` / `Pause timeline`. + +--- + +## 8. Acceptance gate + +- [ ] Pure activity tests pass. +- [ ] Golden fixture semantic/layout tests pass. +- [ ] Dashboard fixture e2e passes locally. +- [ ] Fixture e2e coarse graph overlap check passes. +- [ ] Local PNG dump works when `VISUAL_DEBUGGER_SCREENSHOTS=1` is set. +- [ ] Canonical smoke e2e still passes locally. +- [ ] Screenshots show more than one activity row for concurrent samples. +- [ ] Clicking an activity with a task opens the workspace for that task. +- [ ] Scrubbing sequence updates graph status/topology via existing replay. +- [ ] No assertion relies on agent lane count. diff --git a/docs/superpowers/plans/mas-run-visual-debugger/04-phases.md b/docs/superpowers/plans/mas-run-visual-debugger/04-phases.md new file mode 100644 index 00000000..fdcb4ba0 --- /dev/null +++ b/docs/superpowers/plans/mas-run-visual-debugger/04-phases.md @@ -0,0 +1,230 @@ +# 04 — Phases, Deliverables, Acceptance Gates + +**Status:** draft. +**Scope:** delivery order for the frontend visual debugger branch. One PR is preferred if phases stay small; split after Phase C if review size gets uncomfortable. + +Cross-refs: program in [`00-program.md`](00-program.md), frontend tasks in [`02-frontend-implementation.md`](02-frontend-implementation.md), test contract in [`03-tests-and-e2e.md`](03-tests-and-e2e.md). + +--- + +## Delivery shape + +Each phase should be a clean commit with: + +- Scope: files touched. +- Deliverables: what now works. +- Acceptance gate: exact tests/commands before moving on. + +Do not start the next phase while the current phase is red. + +--- + +## Phase A — Plan and branch scaffold + +**Scope** + +- Create branch `feature/mas-run-visual-debugger-plan`. +- Add this plan folder. +- Keep mockups unmodified except as design reference. + +**Deliverables** + +- `docs/superpowers/plans/mas-run-visual-debugger/` exists. +- Branch records the implementation approach before app edits. + +**Acceptance gate** + +- `git branch --show-current` prints `feature/mas-run-visual-debugger-plan`. +- Plan docs are readable and self-contained. + +--- + +## Phase B — Pure activity model + +**Scope** + +- `ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json` +- `ergon-dashboard/src/features/activity/types.ts` +- `ergon-dashboard/src/features/activity/buildRunActivities.ts` +- `ergon-dashboard/src/features/activity/stackLayout.ts` +- Unit tests for both modules. +- Golden fixture semantic tests from [`06-fast-feedback-and-visual-review.md`](06-fast-feedback-and-visual-review.md). + +**Deliverables** + +- Activity derivation from `WorkflowRunState`, `RunEvent[]`, and `GraphMutationDto[]`. +- Deterministic overlap stack layout. +- Realistic MAS fixture replay proves concurrency is derived from overlap, not agent lanes. +- No React component changes yet. + +**Acceptance gate** + +- `pnpm --dir ergon-dashboard test src/features/activity` +- Golden fixture tests pass locally. +- `pnpm --dir ergon-dashboard run check` + +**Not in this phase** + +- No UI replacement. +- No backend DTO changes. + +--- + +## Phase C — Bottom activity stack UI + +**Scope** + +- `ActivityStackTimeline.tsx` +- `ActivityBar.tsx` +- Wire into `RunWorkspacePage.tsx` behind existing timeline/live mode controls. +- Keep old `MutationTimeline` available until this phase is green. + +**Deliverables** + +- Bottom dock renders activity rows and bars. +- Sequence controls still work. +- Activity click selects task/sequence. +- Empty states are clear. + +**Acceptance gate** + +- `pnpm --dir ergon-dashboard run check` +- Local dashboard fixture page renders without runtime errors. +- Manual browser check against seeded run: graph visible, activity stack visible, workspace opens from activity. + +**Not in this phase** + +- No graph layout tuning unless the new dock breaks existing graph rendering. +- No smoke e2e assertions yet. + +--- + +## Phase D — Time-aware workspace and graph highlights + +**Scope** + +- `TaskWorkspace.tsx` filters task evidence by selected timeline time. +- `DAGCanvas.tsx`/node components accept selected and highlighted task IDs. +- Preserve whole graph at selected `T`. + +**Deliverables** + +- Selecting an activity highlights graph task and opens workspace. +- Selecting a graph node highlights related activity bars. +- Workspace indicates timeline time/sequence. +- Evidence that did not exist at selected time is hidden in timeline mode. + +**Acceptance gate** + +- `pnpm --dir ergon-dashboard run check` +- Component/unit tests for time filtering pass. +- Manual check: scrub backward before a resource appears; workspace no longer shows that resource. + +**Not in this phase** + +- No persisted UI preferences. +- No virtualization. + +--- + +## Phase E — Dashboard fixture e2e + +**Scope** + +- Add concurrent MAS dashboard fixture in `tests/helpers/dashboardFixtures.ts`. +- Add `tests/e2e/activity-stack.spec.ts`. +- Add selectors/ARIA labels required by [`03-tests-and-e2e.md §7`](03-tests-and-e2e.md). +- Add coarse browser geometry checks for graph node overlap. + +**Deliverables** + +- Fast deterministic Playwright test proves the visual debugger contract without real backend execution. +- Screenshot artifact captures the accepted layout shape when `VISUAL_DEBUGGER_SCREENSHOTS=1` is set locally. +- Browser geometry check catches catastrophic overlapping graph boxes without pixel-perfect assertions. +- Local-only PNG dump path works behind `VISUAL_DEBUGGER_SCREENSHOTS=1`. + +**Acceptance gate** + +- `pnpm --dir ergon-dashboard exec playwright test tests/e2e/activity-stack.spec.ts` +- Coarse graph overlap check passes. +- `VISUAL_DEBUGGER_SCREENSHOTS=1 pnpm --dir ergon-dashboard exec playwright test tests/e2e/activity-stack.spec.ts --project=chromium` writes PNGs under `ergon-dashboard/tmp/visual-debugger/`. +- Local PNG review command shows at least two activity rows and full graph canvas. + +**Not in this phase** + +- No backend harness DTO additions unless the fixture spec cannot cover a critical contract. + +--- + +## Phase F — Canonical smoke e2e hardening + +**Scope** + +- Update `tests/e2e/_shared/smoke.ts`. +- Extend screenshot capture points. +- Optionally extend `BackendRunState` and backend harness DTO with `activity_event_count`, `activity_span_count`, `max_concurrency`. + +**Deliverables** + +- Real smoke run opens the new visual debugger. +- Playwright proves graph, activity stack, sequence controls, and workspace are usable. +- Screenshots are useful for PR review. +- No CI visual-diff gate is introduced. + +**Acceptance gate** + +- Local smoke Playwright spec green for at least one benchmark. +- Full e2e matrix remains green before merge. +- Smoke screenshot artifacts are generated as review aids only. +- If harness DTO fields are added, backend unit/integration harness tests pass. + +**Not in this phase** + +- No production DTO expansion unless a user-facing timestamp gap is proven. + +--- + +## Phase G — Cleanup and docs + +**Scope** + +- Delete or rename obsolete `MutationTimeline.tsx`. +- Update dashboard architecture docs if they describe the old event stream/timeline split. +- Add a short note in PR description linking to the accepted mockup and this plan folder. + +**Deliverables** + +- No dead imports/components. +- Standing docs match the shipped dashboard behavior. + +**Acceptance gate** + +- `pnpm --dir ergon-dashboard run check` +- `rg -n "MutationTimeline" ergon-dashboard/src` returns either no matches or only the intentional renamed/reused sequence-control component. +- Final Playwright screenshots attached to PR. +- Final implementation review presents local PNGs for all new UI panels: full debugger page, graph canvas, activity stack bottom dock, and workspace drawer open on a selected task. + +--- + +## Phase size estimates + +| Phase | Scope | Est. diff size | +|---|---|---| +| A | Plan folder | ~500 lines docs | +| B | Activity pure model + golden fixture tests | ~650 LoC | +| C | Activity UI + RunWorkspace wiring + local PNG dump | ~750 LoC | +| D | Workspace filtering + graph highlights | ~300 LoC | +| E | Fixture e2e + browser geometry checks | ~350 LoC | +| F | Smoke hardening + optional harness DTO | ~200-500 LoC | +| G | Cleanup/docs | ~100 LoC | + +--- + +## Failure modes + +- **Activity bars look like lanes:** remove any row grouping by actor/agent. Rows are only collision rows. +- **Graph disappears while scrubbing:** inspect `replayToSequence` input state and current sequence; do not filter by selected task. +- **Workspace shows future evidence:** compare evidence timestamps to selected mutation `created_at`. +- **PNG review reveals cramped layout:** tune spacing/styling, then keep semantic and geometry tests green. Do not add pixel-perfect screenshot diffs in the first PR. +- **E2E flakes on exact counts:** assert minimum visibility and backend DTO truth, not pixel geometry. +- **Backend DTO temptation:** use the decision tree in `01-contracts-and-state.md`; most first-pass needs are frontend-derived. diff --git a/docs/superpowers/plans/mas-run-visual-debugger/05-implementation-shape.md b/docs/superpowers/plans/mas-run-visual-debugger/05-implementation-shape.md new file mode 100644 index 00000000..5fd6a4a3 --- /dev/null +++ b/docs/superpowers/plans/mas-run-visual-debugger/05-implementation-shape.md @@ -0,0 +1,302 @@ +# 05 — Implementation Shape, File Ownership, and Refactor Boundaries + +**Status:** draft for review. +**Scope:** the reviewer-facing "how" plan: what domains the frontend should have after this work, which files are added, which files are refactored, which files are deleted or deliberately left alone, and how tests are laid out. + +Cross-refs: product/DTO stance in [`00-program.md`](00-program.md), activity contracts in [`01-contracts-and-state.md`](01-contracts-and-state.md), phase gates in [`04-phases.md`](04-phases.md). + +--- + +## 1. Target domain map + +After the implementation, the run dashboard should have these frontend domains: + +| Domain | Responsibility | Owns | Must not own | +|---|---|---|---| +| `features/activity` | Turn run state into time-based activity, pack overlaps into stack rows, render bottom dock. | Activity types, derivation, overlap layout, activity timeline UI. | Graph replay, workspace evidence rendering, backend fetching. | +| `features/graph` | Reconstruct and render recursive task topology at selected sequence/time. | Graph mutation contracts, replay reducer, React Flow layout, node components. | Activity stacking, agent lanes, workspace filtering. | +| `components/workspace` | Show task-scoped evidence for the selected task. | Resources, executions, sandbox commands, messages, context events, evaluations for one task. | Timeline packing, graph topology. | +| `components/run` | Page orchestration and cross-panel selection state. | Live/timeline mode, selected task, selected activity, selected sequence, panel composition. | Pure derivation algorithms. | +| `lib/runEvents` | Normalize existing state into a chronological event stream. | Event union, event labels/colors, stream rows. | Visual timeline row allocation. | +| `tests/e2e` + `tests/helpers` | Prove the visual debugger contract with fixture and smoke runs. | Stable selectors, seeded concurrent fixture, screenshot capture, harness assertions. | Pixel-perfect visual diffs. | + +The most important boundary: **activity stack rows are not a domain concept**. They are a layout result. The domain concept is a `RunActivity` with task/time/kind metadata. + +--- + +## 2. Intended folder layout + +Target new files: + +```text +ergon-dashboard/src/features/activity/ + types.ts + buildRunActivities.ts + stackLayout.ts + goldenFixture.test.ts + buildRunActivities.test.ts + stackLayout.test.ts + components/ + ActivityStackTimeline.tsx + ActivityBar.tsx + ActivityKindLegend.tsx + SequenceControls.tsx +``` + +Target modified existing files: + +```text +ergon-dashboard/src/components/run/ + RunWorkspacePage.tsx + +ergon-dashboard/src/components/dag/ + DAGCanvas.tsx + TaskNode.tsx + +ergon-dashboard/src/features/graph/components/ + ContainerNode.tsx + LeafNode.tsx + MutationTimeline.tsx + +ergon-dashboard/src/features/graph/layout/ + hierarchicalLayout.ts + layoutTypes.ts + +ergon-dashboard/src/components/workspace/ + TaskWorkspace.tsx + +ergon-dashboard/src/lib/ + runEvents.ts + statusTokens.ts +``` + +Target test files: + +```text +ergon-dashboard/tests/helpers/ + dashboardFixtures.ts + testHarnessClient.ts + backendHarnessClient.ts + +ergon-dashboard/tests/fixtures/mas-runs/ + concurrent-mas-run.json + nested-delegation-run.json + README.md + +ergon-dashboard/tests/e2e/ + activity-stack.spec.ts + _shared/smoke.ts +``` + +Optional backend files if the e2e harness needs additive DTO truth: + +```text +ergon_core/ergon_core/core/api/test_harness.py +tests/unit/test_test_harness.py +tests/integration/smokes/test_smoke_harness.py +``` + +--- + +## 3. Add, refactor, delete, leave alone + +### Add + +| File | Why it exists | +|---|---| +| `features/activity/types.ts` | Shared activity vocabulary: `RunActivity`, `ActivityKind`, `ActivityStackLayout`, `ActivityStackItem`. | +| `features/activity/buildRunActivities.ts` | Pure state-to-activity derivation. Lets tests verify semantics without React. | +| `features/activity/stackLayout.ts` | Pure overlap packing. Keeps "concurrency stack" independent from rendering. | +| `features/activity/components/ActivityStackTimeline.tsx` | Bottom dock shell: time ruler, rows, controls, legend, selection. | +| `features/activity/components/ActivityBar.tsx` | Single activity marker/span renderer. Keeps bar styling out of the dock shell. | +| `features/activity/components/ActivityKindLegend.tsx` | Small count/filter legend if `ActivityStackTimeline.tsx` gets too large. | +| `features/activity/components/SequenceControls.tsx` | Reusable play/step/speed controls extracted from old mutation timeline behavior. | +| `features/activity/buildRunActivities.test.ts` | Unit coverage for event/span semantics. | +| `features/activity/stackLayout.test.ts` | Unit coverage for overlap packing and max concurrency. | +| `features/activity/goldenFixture.test.ts` | Pumps realistic MAS fixture data through replay/activity/stack derivation. | +| `tests/fixtures/mas-runs/concurrent-mas-run.json` | Stable local fixture for semantic layout and browser visual review. | +| `tests/fixtures/mas-runs/nested-delegation-run.json` | Optional second fixture for deeper recursive nesting once the first path is green. | +| `tests/e2e/activity-stack.spec.ts` | Fast fixture-driven UI contract for the new debugger. | + +### Refactor + +| File | Refactor | +|---|---| +| `RunWorkspacePage.tsx` | Becomes the cross-panel coordinator. It should compute display state, activities, selected time, selected task/activity, and pass props down. It should not implement activity derivation inline. | +| `DAGCanvas.tsx` | Adds highlight props and preserves graph-level controls. No activity logic here. | +| `TaskNode.tsx`, `ContainerNode.tsx`, `LeafNode.tsx` | Add selected/highlight styling and stable test IDs. Avoid redesigning node semantics. | +| `TaskWorkspace.tsx` | Adds time-aware filtering by selected sequence time. Keep the existing evidence sections. | +| `MutationTimeline.tsx` | Either deleted after replacement, or split so reusable sequence controls move to `features/activity/components/SequenceControls.tsx`. | +| `hierarchicalLayout.ts`, `layoutTypes.ts` | Only tune spacing if smoke screenshots still show overlap. Keep dagre and current recursive container model. | +| `runEvents.ts` | Remains event normalization. It may gain helper exports, but it should not pack visual rows. | +| `dashboardFixtures.ts` | Adds a deterministic concurrent MAS fixture, preserving existing fixture exports. | +| `_shared/smoke.ts` | Adds activity stack assertions and screenshots without making visual pixel claims. | +| `activity-stack.spec.ts` | Adds coarse DOM bounding-box overlap checks and optional local screenshot dumping behind `VISUAL_DEBUGGER_SCREENSHOTS=1`. | + +### Delete + +Delete only after the activity stack is wired and tested: + +| File | Delete condition | +|---|---| +| `features/graph/components/MutationTimeline.tsx` | Delete if no code is reused by `SequenceControls.tsx`. | + +No other deletions are planned for the first visual debugger PR. + +### Leave alone + +| Area | Reason | +|---|---| +| Backend execution/control-flow services | The UI problem is representational; backend task orchestration does not need to change. | +| Graph mutation persistence model | Existing sequence/time mutation contract is the right replay primitive. | +| React Flow dependency | The current rendering stack already supports recursive graph rendering. | +| Cohort pages | This work is scoped to run detail pages and smoke screenshots. | +| Production REST schemas | Avoid production DTO expansion unless a real user-facing timestamp gap is proven. | + +--- + +## 4. Data flow after refactor + +```text +REST snapshot / socket updates + | + v +useRunState(runId) --------------------+ + | | + v | +WorkflowRunState | + | | + +--> replayToSequence() ----> displayState at T ----> DAGCanvas + | | + +--> buildRunEvents() ---------+ + | | +/api/runs/{runId}/mutations -----------+ + | + v +buildRunActivities(displayState, events, mutations, currentSequence) + | + v +stackActivities(activities) + | + v +ActivityStackTimeline + | + +--> select task/activity/sequence + | + v +RunWorkspacePage state + | + +--> DAGCanvas highlight/selection + +--> TaskWorkspace selected task + selected time +``` + +Selection rules: + +- Graph node click sets `selectedTaskId`. +- Activity click sets `selectedActivityId`, sets `selectedTaskId` when present, and jumps to `activity.sequence` when present. +- Sequence scrub changes `currentSequence`; it does not clear task selection unless the selected task does not exist at that sequence. +- Workspace reads selected task from `displayState`, not live state, when timeline mode is active. + +--- + +## 5. Test layout + +### Pure unit tests + +```text +ergon-dashboard/src/features/activity/buildRunActivities.test.ts +ergon-dashboard/src/features/activity/stackLayout.test.ts +``` + +These tests should use small inline fixture builders. Do not import Playwright, React, or browser APIs. + +### Component-level tests if local harness exists + +If the dashboard already has React component tests, add: + +```text +ergon-dashboard/src/features/activity/components/ActivityStackTimeline.test.tsx +``` + +This test should assert: + +- rows render from layout items. +- clicking a bar calls `onActivityClick`. +- controls call `onSequenceChange`. + +If the project does not have component-test infrastructure, skip this and rely on pure unit + Playwright. + +### Fixture e2e + +```text +ergon-dashboard/tests/e2e/activity-stack.spec.ts +``` + +This is the fast UI contract: + +- seed concurrent MAS fixture. +- open run page. +- assert graph, stack, and workspace regions. +- assert more than one stack row. +- assert no catastrophic graph-node bounding-box overlaps. +- click activity -> workspace opens. +- scrub sequence -> current sequence indicator changes. +- dump local PNGs only when `VISUAL_DEBUGGER_SCREENSHOTS=1`. + +### Golden fixture semantic tests + +```text +ergon-dashboard/src/features/activity/goldenFixture.test.ts +ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts +ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts +``` + +These are the fast feedback loop for the exact failure mode we want to avoid: + +- replay fixture to selected sequence `T`; +- assert whole graph expected at `T`; +- assert no overlapping graph boxes in pure layout output; +- assert activity stack max concurrency; +- assert row assignment does not depend on agent/worker identity; +- assert future task evidence is hidden in timeline mode. + +### Local PNG review + +```text +ergon-dashboard/tmp/visual-debugger/ + run-full.png + graph-canvas.png + activity-stack.png + workspace-open.png +``` + +Generated only by local command: + +```bash +VISUAL_DEBUGGER_SCREENSHOTS=1 pnpm --dir ergon-dashboard exec playwright test tests/e2e/activity-stack.spec.ts --project=chromium +``` + +These files are for human review while building. They should not be committed and should not be required in CI. + +### Smoke e2e + +```text +ergon-dashboard/tests/e2e/_shared/smoke.ts +``` + +This is the real integration contract: + +- backend harness proves graph/resources/evaluations are real. +- dashboard proves visual debugger renders real run state. +- screenshots capture full page and activity stack. + +--- + +## 6. Review questions before implementation + +1. Should `features/activity` own `SequenceControls.tsx`, or should sequence controls live under `features/graph` because sequences come from graph mutations? +2. Should `ActivityStackTimeline` support filtering by kind in the first PR, or only render the legend counts? +3. Should `TaskWorkspace` hide future evidence in timeline mode, or show it disabled with "after selected time" labels? +4. Should the old event stream stay visible by default, or be collapsed once the activity stack exists? +5. Should fixture e2e be required before smoke e2e changes, or can smoke drive the first UI contract directly? +6. Should `nested-delegation-run.json` ship in the first PR, or should the first PR use only `concurrent-mas-run.json` and add the deeper fixture after the UI stabilizes? diff --git a/docs/superpowers/plans/mas-run-visual-debugger/06-fast-feedback-and-visual-review.md b/docs/superpowers/plans/mas-run-visual-debugger/06-fast-feedback-and-visual-review.md new file mode 100644 index 00000000..d7a16b52 --- /dev/null +++ b/docs/superpowers/plans/mas-run-visual-debugger/06-fast-feedback-and-visual-review.md @@ -0,0 +1,309 @@ +# 06 — Fast Feedback, TDD, and Local Visual Review + +**Status:** draft. +**Scope:** the feedback loop that prevents another unreadable MAS layout from landing: test-first semantic layout checks, coarse browser geometry assertions, and local-only PNG dumps for human visual review. + +Cross-refs: test contract in [`03-tests-and-e2e.md`](03-tests-and-e2e.md), implementation shape in [`05-implementation-shape.md`](05-implementation-shape.md). + +--- + +## 1. Why this exists + +The prior UI failure was not mainly a data-fetching failure. It was a semantics/layout failure: + +- recursive task containers were hard to read; +- graph state at selected time `T` was not clearly represented; +- timeline lanes implied stable agents even though agents/workers can join and leave; +- overlapping work was not represented as concurrency; +- visual density problems were not caught by tests. + +This plan adds a fast feedback loop before full e2e smoke: + +1. Pure TDD tests for semantics and layout algorithms. +2. Coarse browser geometry checks for catastrophic overlap. +3. Local-only PNG dumps that humans inspect while building the UI. + +PNG review is required for development/review discipline, but it is **not** a CI gate in the first PR. + +--- + +## 2. Test-first policy for this feature + +Use TDD for the core behavior: + +- write the failing semantic/layout test; +- run it and confirm it fails for the expected reason; +- implement the smallest code to pass; +- keep the test as a regression guard. + +Do this for: + +- activity derivation; +- activity overlap packing; +- graph snapshot at sequence `T`; +- no graph node overlap for the golden fixture; +- activity click -> task/sequence selection; +- workspace time filtering. + +Do not use TDD for throwaway visual CSS tweaking. For CSS, use local PNG review and coarse browser checks. + +--- + +## 3. Golden fixture data + +Add deterministic fixture data that represents the MAS case we care about. + +Target files: + +```text +ergon-dashboard/tests/fixtures/mas-runs/ + concurrent-mas-run.json + nested-delegation-run.json + README.md +``` + +`concurrent-mas-run.json` should include: + +- full serialized run snapshot; +- graph mutations sorted by sequence; +- expected sequence checkpoints; +- expected graph node IDs/slugs at each checkpoint; +- expected activity concurrency facts. + +Example shape: + +```json +{ + "name": "concurrent-mas-run", + "runState": {}, + "mutations": [], + "checkpoints": [ + { + "sequence": 12, + "expectedTaskSlugs": ["root", "d_root", "d_left", "d_right", "d_join", "l_1"], + "expectedVisibleResourceNames": [], + "expectedMaxConcurrency": 3 + } + ] +} +``` + +Rules: + +- Keep fixture JSON small enough to review. +- Prefer real captured run shape when available, then minimize it. +- Do not include secrets, model outputs, or large artifacts. +- If the fixture comes from a real run/VCR capture, sanitize IDs only if tests do not depend on specific UUID shape. + +--- + +## 4. Pure semantic layout tests + +Create: + +```text +ergon-dashboard/src/features/activity/goldenFixture.test.ts +ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts +ergon-dashboard/src/components/workspace/timeFiltering.test.ts +``` + +### Activity fixture test + +This test pumps fixture data through pure functions: + +```typescript +import fixture from "../../../tests/fixtures/mas-runs/concurrent-mas-run.json"; +import { parseGraphMutationDtoArray } from "@/features/graph/contracts/graphMutations"; +import { replayToSequence } from "@/features/graph/state/graphMutationReducer"; +import { buildRunActivities } from "./buildRunActivities"; +import { stackActivities } from "./stackLayout"; +import { buildRunEvents } from "@/lib/runEvents"; +import { deserializeRunState } from "@/lib/runState"; + +it("derives concurrency from overlapping activity rather than agent lanes", () => { + const liveState = deserializeRunState(fixture.runState); + const mutations = parseGraphMutationDtoArray(fixture.mutations); + const checkpoint = fixture.checkpoints.find((c) => c.sequence === 12)!; + const displayState = replayToSequence(mutations, checkpoint.sequence, emptyRunStateFrom(liveState), new Map()); + const events = buildRunEvents(displayState); + const activities = buildRunActivities({ runState: displayState, events, mutations, currentSequence: checkpoint.sequence }); + const stack = stackActivities(activities); + + expect(stack.maxConcurrency).toBe(checkpoint.expectedMaxConcurrency); + expect(new Set(activities.map((activity) => activity.kind))).toEqual( + expect.arrayContaining(["execution", "graph", "artifact", "evaluation"]), + ); + expect(stack.items.some((item) => item.activity.actor && item.row === Number(item.activity.actor))).toBe(false); +}); +``` + +The exact helper names can change during implementation, but the assertion intent must stay: + +- concurrency comes from overlap; +- activities are not grouped by agent/worker lane; +- graph mutations remain sequence-addressable. + +### Graph layout fixture test + +This test runs the same fixture through replay + layout and asserts no overlapping rendered boxes. + +```typescript +it("lays out the whole recursive graph at sequence T without overlapping node boxes", () => { + const displayState = replayFixtureToSequence("concurrent-mas-run", 12); + const result = computeHierarchicalLayout( + displayState.tasks, + calculateExpandedContainers(displayState.tasks, Infinity), + "", + undefined, + null, + "LR", + new Set(), + ); + + expect(new Set(result.nodes.map((node) => node.id))).toEqual(expectedWholeGraphNodeIdsAtSequence(12)); + expect(findOverlappingNodeBoxes(result.nodes)).toEqual([]); +}); +``` + +`findOverlappingNodeBoxes` should compare coarse rectangles from React Flow node `position`, `width`, and `height`. This is not a pixel-perfect visual diff; it catches catastrophic overlap. + +### Workspace time filtering test + +Extract filtering into a pure helper if `TaskWorkspace.tsx` is otherwise hard to test: + +```text +ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.ts +ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts +``` + +Assert: + +- resource created after selected time is hidden; +- execution started before selected time is visible; +- message created after selected time is hidden; +- live mode returns unfiltered evidence. + +--- + +## 5. Browser geometry checks + +Add coarse checks to `ergon-dashboard/tests/e2e/activity-stack.spec.ts`. + +Use DOM bounding boxes for rendered elements: + +```typescript +async function boxesFor(page: Page, selector: string) { + return page.locator(selector).evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }), + ); +} + +function overlappingPairs(boxes: { x: number; y: number; width: number; height: number }[]) { + const pairs: [number, number][] = []; + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + if (boxesOverlap(boxes[i], boxes[j])) pairs.push([i, j]); + } + } + return pairs; +} + +expect(overlappingPairs(await boxesFor(page, '[data-testid^="graph-node-"]'))).toEqual([]); +``` + +Rules: + +- Use coarse overlap checks only. +- Do not assert exact coordinates. +- Ignore tiny overlaps below 2px if React Flow transform/subpixel rendering creates false positives. +- Keep these checks on fixture e2e first; only add to real smoke if stable. + +--- + +## 6. Local-only PNG dump + +Add a developer-only screenshot command/spec path. This is for us while building and reviewing. It does **not** need to run in CI. + +Target output: + +```text +ergon-dashboard/tmp/visual-debugger/ + run-full.png + graph-canvas.png + activity-stack.png + workspace-open.png +``` + +Suggested command: + +```bash +pnpm --dir ergon-dashboard exec playwright test tests/e2e/activity-stack.spec.ts --project=chromium +``` + +The spec should write screenshots when `VISUAL_DEBUGGER_SCREENSHOTS=1`: + +```typescript +const shouldDumpScreenshots = process.env.VISUAL_DEBUGGER_SCREENSHOTS === "1"; + +if (shouldDumpScreenshots) { + await page.screenshot({ + path: "tmp/visual-debugger/run-full.png", + fullPage: true, + }); + await page.getByTestId("graph-canvas").screenshot({ + path: "tmp/visual-debugger/graph-canvas.png", + }); + await page.getByTestId("activity-stack-region").screenshot({ + path: "tmp/visual-debugger/activity-stack.png", + }); + await page.getByTestId("workspace-region").screenshot({ + path: "tmp/visual-debugger/workspace-open.png", + }); +} +``` + +Recommended local command: + +```bash +VISUAL_DEBUGGER_SCREENSHOTS=1 pnpm --dir ergon-dashboard exec playwright test tests/e2e/activity-stack.spec.ts --project=chromium +``` + +Review rules: + +- Inspect PNGs locally during development. +- Look for cramped graph, overlapping containers, unreadable labels, poor activity row density, confusing color hierarchy, and workspace clipping. +- Treat final implementation review as incomplete until the implementer presents the four panel PNGs to the user/reviewer: `run-full.png`, `graph-canvas.png`, `activity-stack.png`, and `workspace-open.png`. +- Do not commit PNGs from `tmp/visual-debugger/`. +- Do not block CI on PNG generation or screenshot diffs in the first PR. + +--- + +## 7. What becomes a hard gate + +Hard gates: + +- pure semantic tests pass; +- fixture e2e renders graph/stack/workspace; +- coarse graph node overlap check passes for golden fixture; +- no test asserts fixed agent lane counts; +- local screenshot command works when run manually. + +Not hard gates in first PR: + +- pixel-perfect screenshot diff; +- exact `x/y` coordinate assertions; +- local PNG files existing in CI; +- visual comparison against the HTML mockup. + +--- + +## 8. Phase impact + +This adds work to the phase plan: + +- Phase B adds golden fixture semantic tests before implementing activity/layout code. +- Phase E adds browser geometry overlap checks and local screenshot dumping behind `VISUAL_DEBUGGER_SCREENSHOTS=1` to fixture e2e. +- Phase F keeps screenshot artifacts for real smoke/PR review, but no CI visual-diff gate. diff --git a/docs/superpowers/plans/mas-run-visual-debugger/README.md b/docs/superpowers/plans/mas-run-visual-debugger/README.md new file mode 100644 index 00000000..f3677b19 --- /dev/null +++ b/docs/superpowers/plans/mas-run-visual-debugger/README.md @@ -0,0 +1,22 @@ +# MAS Run Visual Debugger — plan folder + +**Status:** draft for review — branch planning only; no frontend implementation landed yet. +**Date:** 2026-04-26. +**Branch:** `feature/mas-run-visual-debugger-plan`. +**Design reference:** `ergon-dashboard/mockups/mas-activity-stack-debugger.html`. + +## Read order + +1. [`00-program.md`](00-program.md) — product goal, non-goals, UX invariants, DTO stance, merge checklist. +2. [`05-implementation-shape.md`](05-implementation-shape.md) — reviewer-facing "how": domains, file ownership, add/refactor/delete plan, test layout. +3. [`01-contracts-and-state.md`](01-contracts-and-state.md) — event/DTO inventory, activity-stack domain model, replay rules, and where backend contract changes are actually needed. +4. [`02-frontend-implementation.md`](02-frontend-implementation.md) — component and layout plan for the three-pane visual debugger. +5. [`03-tests-and-e2e.md`](03-tests-and-e2e.md) — unit/component/e2e coverage, screenshot contract, and harness DTO additions. +6. [`06-fast-feedback-and-visual-review.md`](06-fast-feedback-and-visual-review.md) — TDD fixture loop, coarse layout geometry checks, and local-only PNG review workflow. +7. [`04-phases.md`](04-phases.md) — phased delivery order with acceptance gates. + +## Principle + +The dashboard should be a visual debugger for a MAS run, not an agent swimlane view. The durable axes are graph state, task-scoped events, and wall-clock overlap. Agents/workers are labels on events, not layout anchors. + +When documents disagree, `00-program.md` wins. When `00-program.md` and code reality disagree, update `00-program.md` first and re-review before implementing. diff --git a/ergon-dashboard/.gitignore b/ergon-dashboard/.gitignore index cc0e351e..8032f00e 100644 --- a/ergon-dashboard/.gitignore +++ b/ergon-dashboard/.gitignore @@ -10,6 +10,7 @@ /coverage /test-results/ /playwright-report/ +/tmp/ # next.js /.next/ diff --git a/ergon-dashboard/mockups/mas-activity-stack-debugger.html b/ergon-dashboard/mockups/mas-activity-stack-debugger.html new file mode 100644 index 00000000..6342d54d --- /dev/null +++ b/ergon-dashboard/mockups/mas-activity-stack-debugger.html @@ -0,0 +1,780 @@ + + + + + + MAS Activity Stack Debugger Mockup + + + +
+
+
+ ergon/researchrubrics-vanilla/run-8427-ab3f/sample 42 +
+
+
+
+

MAS Activity Stack Debugger

+ Executing + Activity stack + Event tracks + Live +
+
+ Cursor: sequence 42 / 119 + Bottom dock shows overlap of active work, not fixed swimlanes + Graph replayed from mutation WAL +
+
+
+
Nodes10
+
Mutations119
+
Events247
+
Active @ T6
+
+
+
+ +
+
+
+ Main viewer · whole recursive graph at selected sequence + Timeline selects sequence T. Graph shows every node that exists at T. +
+
+
+ Snapshot @ seq 42 + Whole graph at T + Selected task highlighted +
+
+ Replay contract + Future nodes are absent. Existing pending/evaluation nodes remain visible. The highlight is selection, not filtering. +
+ + + + + + + + + + +
+
Diamond root · graph snapshot10 nodes visible @ seq 42
+
+
+
Planning branch2 subtasks
+
+
+
Exploration branch3 subtasks
+
+
+
Repro looplevel 2
+
+
+
Implementation branch3 subtasks
+
+
+
Evaluation branch2 subtasks
+
+ +
+ + running +

Diamond root

+

Coordinates the graph and propagation.

+
+
done

Plan attack

+
done

Assign workers

+ +
+ + completed +

Inspect repo

+

Produced candidate files and context.

+
+
+ + selected +

Reproduce issue

+

Started at the selected sequence.

+
+
+ + ready +

Patch strategy

+

Ready, waiting on repro output.

+
+ +
done

Run failing test

+
running

Capture traceback

+
ready

Patch file

+
pending

Run tests

+
pending

Summarize

+
pending

Score output

+
pending

Persist result

+ +
+ J + pending +

Join + score

+

Pending join node already exists at T.

+
+
+
+ + + +
+
+ Bottom dock · Activity Stack + Rows are overlap layers, not fixed agent or event-kind swimlanes. +
+
+
+ 21:3321:3421:3521:3621:3721:3821:3921:40 +
+
+ Concurrent activity stack
+ bars stack only when they overlap +
+
+
+
+
+
+ +
create graph mutation burst
+
inspect repo task execution
+
message thread
+
candidate files
+ +
reproduce issue running
+
ready → running
+
patch strategy waits
+
tool call
+
repro.log
+ +
patch file
+
run tests
+
task eval
+
run score
+ + + + +
+
+
+ Color = kind + Vertical stack = overlap + Click bar = select task/span + Click instant = jump event + Toggle to event tracks for audit view +
+
+ Active under cursor · seq 42 + Reproduce issue is running while patch strategy waits, a tool call fires, and the graph mutation records ready → running. This answers “what is happening concurrently right now?” +
+
+
+
+
+ + diff --git a/ergon-dashboard/mockups/mas-concurrency-debugger.html b/ergon-dashboard/mockups/mas-concurrency-debugger.html new file mode 100644 index 00000000..cac655b2 --- /dev/null +++ b/ergon-dashboard/mockups/mas-concurrency-debugger.html @@ -0,0 +1,1092 @@ + + + + + + MAS Concurrency Debugger Mockup + + + +
+
+
+ ergon/researchrubrics-vanilla/run-8427-ab3f/sample 42 +
+
+
+
+

Concurrency Debugger

+ Executing + Record + Live + Phase summary +
+
+ Sequence cursor: 42 / 119 + Selected time: 21:38:12.408 + Snapshot mode: graph replayed to selected sequence +
+
+
+
Agents5
+
Tasks10
+
Events119
+
Score-
+
+
+
+ +
+
+
+ Pane 2 · Main viewer: whole recursive graph at selected sequence + Graph is replayed to time T. Bottom timeline controls time; clicking a node opens Pane 3. +
+
+
+ Snapshot @ seq 42 + Whole graph at T + Recursive containers + Selected node highlighted +
+
+ State at 21:38:12 + This is the full graph as it exists at sequence 42. The selected node is highlighted, but sibling branches are still visible. +
+ + + + + + + + + + + + + + + +
+
+ Diamond root · whole graph snapshot + 10 nodes visible @ seq 42 +
+
+
+
+ Exploration branch + 3 subtasks +
+
+
+
+ Planning branch + 2 subtasks +
+
+
+
+ Repro loop + level 2 +
+
+
+
+ Implementation branch + 3 subtasks +
+
+
+
+ Evaluation branch + 2 subtasks +
+
+ +
+ + running +

Diamond root

+

Delegated branch work and monitors dependencies.

+
+ +
+ + completed +

Inspect repo

+

Produced candidate files and context.

+
+ +
+ + changed now +

Reproduce issue

+

Expanded because this task itself delegated a nested repro loop.

+
+ +
+ done +

Plan attack

+
+ +
+ done +

Assign workers

+
+ +
+ done +

Run failing test

+
+ +
+ running +

Capture traceback

+
+ +
+ + ready +

Patch strategy

+

Waiting for repro evidence.

+
+ +
+ ready +

Patch file

+
+ +
+ pending +

Run tests

+
+ +
+ pending +

Summarize

+
+ +
+ pending +

Score output

+
+ +
+ pending +

Persist result

+
+ +
+ + ancestor view +

Join + score

+

Exists at this time as a pending join node.

+
+ Selected node lives inside Exploration → Repro loop +
+
+ + + +
+
+ Pane 1 · Bottom transport: agent swimlane timeline + Apple-style scrubber: concurrency, event ordering, and replay cursor +
+
+
+ 21:3321:3421:3521:3621:3721:3821:39 +
+
+
Mmanager
+
Aanalyst
+
Rrepro
+
Ppatcher
+
Eevaluator
+
+
+
+
plan + delegate
+
coordinate
+ + +
+
+
inspect repo
+ + +
+
+
reproduce issue
+ + +
+
+
waiting
+
patch strategy
+
+
+
pending
+ +
+
+
+
+ Clicked event: worker_started + Repro worker begins “reproduce issue.” Pane 2 is replayed to sequence 42 and highlights the changed node. +
+
+
+
+
+ + diff --git a/ergon-dashboard/mockups/mas-sequence-debugger.html b/ergon-dashboard/mockups/mas-sequence-debugger.html new file mode 100644 index 00000000..a7a1e3e1 --- /dev/null +++ b/ergon-dashboard/mockups/mas-sequence-debugger.html @@ -0,0 +1,803 @@ + + + + + + MAS Sequence Debugger Mockup + + + +
+
+
+ ergon/researchrubrics-vanilla/run-8427-ab3f/sample 42 +
+
+
+
+

MAS Sequence Debugger

+ Executing + Record + Live + Task filters +
+
+ Cursor: sequence 42 / 119 + Selected event: worker_started on Reproduce issue + Graph replayed from mutation WAL +
+
+
+
Nodes10
+
Mutations119
+
Events247
+
Score-
+
+
+
+ +
+
+
+ Main viewer · whole recursive graph at selected sequence + Timeline selects sequence T. Graph shows every node that exists at T. +
+
+
+ Snapshot @ seq 42 + Whole graph at T + Selected task highlighted +
+
+ Replay contract + Future nodes are absent. Existing pending/evaluation nodes remain visible. The highlight is selection, not filtering. +
+ + + + + + + + + + +
+
Diamond root · graph snapshot10 nodes visible @ seq 42
+
+
+
Planning branch2 subtasks
+
+
+
Exploration branch3 subtasks
+
+
+
Repro looplevel 2
+
+
+
Implementation branch3 subtasks
+
+
+
Evaluation branch2 subtasks
+
+ +
+ + running +

Diamond root

+

Coordinates the graph and propagation.

+
+
done

Plan attack

+
done

Assign workers

+ +
+ + completed +

Inspect repo

+

Produced candidate files and context.

+
+
+ + selected +

Reproduce issue

+

Started at the selected sequence.

+
+
+ + ready +

Patch strategy

+

Ready, waiting on repro output.

+
+ +
done

Run failing test

+
running

Capture traceback

+ +
ready

Patch file

+
pending

Run tests

+
pending

Summarize

+ +
pending

Score output

+
pending

Persist result

+ +
+ J + pending +

Join + score

+

Pending join node already exists at T.

+
+
+
+ + + +
+
+ Bottom dock · Run Record / Sequence Debugger + Stable semantic tracks. Actor/worker is metadata, not the lane axis. +
+
+
+ seq 0153045607590119 +
+
+
Graph
+
Execution
+
Talk
+
Artifacts
+
Evaluation
+
+
+
+ + + + +
+
+ inspect repo + reproduce issue + patch waits + patch file +
+
+ + + + +
+
+ + + +
+
+ + +
+
+
+
+ Click event: jump graph to sequence + Click span: select related task + Filter by task/kind/actor + Actor shown on hover +
+
+ Selected record: task.transition · seq 42 + Reproduce issue moved ready → running. The graph is replayed to this sequence; the drawer scopes details to the selected task. +
+
+
+
+
+ + diff --git a/ergon-dashboard/src/app/api/runs/[runId]/mutations/route.ts b/ergon-dashboard/src/app/api/runs/[runId]/mutations/route.ts index 8358a635..9da3ad3a 100644 --- a/ergon-dashboard/src/app/api/runs/[runId]/mutations/route.ts +++ b/ergon-dashboard/src/app/api/runs/[runId]/mutations/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; +import { config } from "@/lib/config"; import { fetchErgonApi } from "@/lib/serverApi"; +import { getHarnessRunMutations } from "@/lib/testing/dashboardHarness"; interface RouteContext { params: Promise<{ @@ -12,6 +14,12 @@ export async function GET(_request: Request, context: RouteContext) { const { runId } = await context.params; try { + if (config.enableTestHarness) { + const harnessMutations = getHarnessRunMutations(runId); + if (harnessMutations) { + return NextResponse.json(harnessMutations); + } + } const response = await fetchErgonApi(`/runs/${runId}/mutations`); const body = await response.json(); if (response.ok) { diff --git a/ergon-dashboard/src/components/dag/DAGCanvas.tsx b/ergon-dashboard/src/components/dag/DAGCanvas.tsx index 4815ec0d..9571e09c 100644 --- a/ergon-dashboard/src/components/dag/DAGCanvas.tsx +++ b/ergon-dashboard/src/components/dag/DAGCanvas.tsx @@ -44,6 +44,7 @@ interface DAGCanvasProps { isSubscribed?: boolean; onTaskClick?: (taskId: string) => void; selectedTaskId?: string | null; + highlightedTaskIds?: ReadonlySet; } /** @@ -79,6 +80,7 @@ function DAGCanvasInner({ isSubscribed = false, onTaskClick, selectedTaskId, + highlightedTaskIds = new Set(), }: DAGCanvasProps) { const [expandedDepth, setExpandedDepth] = useState(DEFAULT_EXPANDED_DEPTH); const [manualExpansions, setManualExpansions] = useState>(new Set()); @@ -162,6 +164,7 @@ function DAGCanvasInner({ selectedTaskId, "LR", newNodeIds, + highlightedTaskIds, ); setNodes(result.nodes as TaskNodeType[]); @@ -174,6 +177,7 @@ function DAGCanvasInner({ onTaskClick, selectedTaskId, newNodeIds, + highlightedTaskIds, setNodes, setEdges, ]); @@ -354,7 +358,7 @@ function DAGCanvasInner({ diff --git a/ergon-dashboard/src/components/dag/TaskNode.tsx b/ergon-dashboard/src/components/dag/TaskNode.tsx index 7527be91..dfe589c8 100644 --- a/ergon-dashboard/src/components/dag/TaskNode.tsx +++ b/ergon-dashboard/src/components/dag/TaskNode.tsx @@ -66,6 +66,7 @@ function TaskNodeComponent({ data }: NodeProps) { onClick={onClick} selected={selected} dimmed={dimmed} + highlighted={highlighted} containerWidth={dims?.width ?? 260} containerHeight={dims?.height ?? 100} layoutDirection={graphLayoutDirection} diff --git a/ergon-dashboard/src/components/run/RunWorkspacePage.tsx b/ergon-dashboard/src/components/run/RunWorkspacePage.tsx index 28de8fbd..56c55a52 100644 --- a/ergon-dashboard/src/components/run/RunWorkspacePage.tsx +++ b/ergon-dashboard/src/components/run/RunWorkspacePage.tsx @@ -8,7 +8,9 @@ import { StatusBadge } from "@/components/common/StatusBadge"; import { RunStatusBar } from "@/components/run/RunStatusBar"; import { UnifiedEventStream } from "@/components/run/UnifiedEventStream"; import { TaskWorkspace } from "@/components/workspace/TaskWorkspace"; -import { MutationTimeline } from "@/features/graph/components/MutationTimeline"; +import { ActivityStackTimeline } from "@/features/activity/components/ActivityStackTimeline"; +import { buildRunActivities } from "@/features/activity/buildRunActivities"; +import type { RunActivity } from "@/features/activity/types"; import { parseGraphMutationDtoArray, type GraphMutationDto, @@ -31,6 +33,18 @@ function formatPercent(value: number | null): string { return `${(value * 100).toFixed(1)}%`; } +function nearestMutationAtOrBefore( + mutations: GraphMutationDto[], + sequence: number, +): GraphMutationDto | null { + let selected: GraphMutationDto | null = null; + for (const mutation of mutations) { + if (mutation.sequence > sequence) break; + selected = mutation; + } + return selected ?? mutations[0] ?? null; +} + export function RunWorkspacePage({ runId, cohortId, @@ -43,6 +57,7 @@ export function RunWorkspacePage({ initialCohortDetail?: CohortDetail | null; }) { const [selectedTaskId, setSelectedTaskId] = useState(null); + const [selectedActivityId, setSelectedActivityId] = useState(null); const [selectionNotice, setSelectionNotice] = useState(null); const [statusFilter, setStatusFilter] = useState(null); const [isStreamOpen, setIsStreamOpen] = useState(true); @@ -56,6 +71,7 @@ export function RunWorkspacePage({ const [playbackSpeed, setPlaybackSpeed] = useState(1); const [mutations, setMutations] = useState([]); const snapshotCache = useRef(new Map()); + const requestedSequenceRef = useRef(null); // Fetch mutations when entering timeline mode useEffect(() => { @@ -68,9 +84,14 @@ export function RunWorkspacePage({ const parsed = parseGraphMutationDtoArray(data); setMutations(parsed); snapshotCache.current.clear(); - setCurrentSequence( - parsed.length > 0 ? parsed[parsed.length - 1].sequence : 0, - ); + const requestedSequence = requestedSequenceRef.current; + requestedSequenceRef.current = null; + const defaultMutation = parsed[parsed.length - 1] ?? null; + const requestedMutation = + requestedSequence === null + ? null + : nearestMutationAtOrBefore(parsed, requestedSequence); + setCurrentSequence((requestedMutation ?? defaultMutation)?.sequence ?? 0); }) .catch(() => { if (!cancelled) setMutations([]); @@ -136,6 +157,30 @@ export function RunWorkspacePage({ // trims the feed in lockstep. const events = useMemo(() => buildRunEvents(displayState), [displayState]); + const activities = useMemo( + () => + buildRunActivities({ + runState: displayState, + events, + mutations, + currentSequence: timelineMode === "timeline" ? currentSequence : null, + }), + [displayState, events, mutations, timelineMode, currentSequence], + ); + + const selectedTimelineTime = useMemo(() => { + if (timelineMode !== "timeline") return null; + return nearestMutationAtOrBefore(mutations, currentSequence)?.created_at ?? null; + }, [timelineMode, mutations, currentSequence]); + + const highlightedTaskIds = useMemo(() => { + const ids = new Set(); + if (selectedTaskId) ids.add(selectedTaskId); + const selectedActivity = activities.find((activity) => activity.id === selectedActivityId); + if (selectedActivity?.taskId) ids.add(selectedActivity.taskId); + return ids; + }, [activities, selectedActivityId, selectedTaskId]); + // D7: keyboard shortcuts — Esc closes selection, `t` toggles timeline, // `e` toggles event stream, `1-6` filters by lifecycle status. useEffect(() => { @@ -192,9 +237,28 @@ export function RunWorkspacePage({ const handleTaskClick = (taskId: string) => { setSelectionNotice(null); + setSelectedActivityId(null); setSelectedTaskId((prev) => (prev === taskId ? null : taskId)); }; + const handleSequenceChange = (sequence: number) => { + const mutation = nearestMutationAtOrBefore(mutations, sequence); + setCurrentSequence(mutation?.sequence ?? sequence); + }; + + const handleActivityClick = (activity: RunActivity) => { + setSelectionNotice(null); + setSelectedActivityId(activity.id); + if (activity.sequence !== null) { + requestedSequenceRef.current = activity.sequence; + if (timelineMode !== "timeline") setTimelineMode("timeline"); + handleSequenceChange(activity.sequence); + } + if (activity.taskId) { + setSelectedTaskId(activity.taskId); + } + }; + return (
- {timelineMode === "timeline" && mutations.length > 0 && ( + {activities.length > 0 && (
- setIsPlaying((p) => !p)} speed={playbackSpeed} onSpeedChange={setPlaybackSpeed} + onActivityClick={handleActivityClick} />
)} @@ -394,7 +463,8 @@ export function RunWorkspacePage({ }} onSequenceClick={(seq) => { if (timelineMode !== "timeline") setTimelineMode("timeline"); - setCurrentSequence(seq); + requestedSequenceRef.current = seq; + handleSequenceChange(seq); }} /> @@ -409,8 +479,11 @@ export function RunWorkspacePage({ onClearSelection={() => setSelectedTaskId(null)} onJumpToSequence={(seq) => { if (timelineMode !== "timeline") setTimelineMode("timeline"); - setCurrentSequence(seq); + requestedSequenceRef.current = seq; + handleSequenceChange(seq); }} + selectedTime={selectedTimelineTime} + selectedSequence={timelineMode === "timeline" ? currentSequence : null} /> ) : ( diff --git a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx index 95411354..2ab3e00b 100644 --- a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx +++ b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx @@ -10,6 +10,7 @@ import { TaskTransitionLog } from "@/components/workspace/TaskTransitionLog"; import { ContextEventLog } from "@/features/graph/components/ContextEventLog"; import type { WorkflowRunState } from "@/lib/types"; import { formatTaskWallTimestamp } from "@/features/graph/utils/taskTiming"; +import { filterTaskEvidenceForTime } from "./filterTaskEvidenceForTime"; function EmptySection({ message }: { message: string }) { return
{message}
; @@ -43,17 +44,30 @@ export function TaskWorkspace({ error, onClearSelection, onJumpToSequence, + selectedTime = null, + selectedSequence = null, }: { runState: WorkflowRunState | null; taskId: string | null; error: string | null; onClearSelection?: () => void; onJumpToSequence?: (sequence: number) => void; + selectedTime?: string | null; + selectedSequence?: number | null; }) { const { task, resources, executions, sandbox, threads, evaluation, dependencies, isLoading } = useTaskDetails(runState, taskId); const contextEvents = taskId && runState ? (runState.contextEventsByTask.get(taskId) ?? []) : []; + const filteredEvidence = filterTaskEvidenceForTime({ + resources, + executions, + sandbox, + threads, + evaluation, + contextEvents, + selectedTime, + }); if (!taskId) { return ( @@ -89,13 +103,13 @@ export function TaskWorkspace({ } const primarySection = - resources.length > 0 + filteredEvidence.resources.length > 0 ? "outputs" - : evaluation + : filteredEvidence.evaluation ? "evaluation" - : threads.length > 0 + : filteredEvidence.threads.length > 0 ? "communication" - : sandbox + : filteredEvidence.sandbox ? "sandbox" : "overview"; @@ -111,6 +125,14 @@ export function TaskWorkspace({

{task.name}

+ {selectedSequence !== null && ( + + Viewing seq {selectedSequence} + + )} {onClearSelection && ( + ); +} diff --git a/ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx b/ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx new file mode 100644 index 00000000..423b13db --- /dev/null +++ b/ergon-dashboard/src/features/activity/components/ActivityStackTimeline.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import type { GraphMutationDto } from "@/features/graph/contracts/graphMutations"; +import { stackActivities } from "@/features/activity/stackLayout"; +import type { ActivityKind, RunActivity } from "@/features/activity/types"; +import { ActivityBar, activityKindLabel } from "./ActivityBar"; + +interface ActivityStackTimelineProps { + activities: RunActivity[]; + mutations: GraphMutationDto[]; + currentSequence: number; + selectedTaskId: string | null; + selectedActivityId: string | null; + isPlaying: boolean; + speed: number; + onSequenceChange: (sequence: number) => void; + onTogglePlay: () => void; + onSpeedChange: (speed: number) => void; + onActivityClick: (activity: RunActivity) => void; +} + +const SPEED_OPTIONS = [1, 2, 5, 10] as const; +const MIN_DELAY_MS = 50; +const MAX_DELAY_MS = 2000; +const ROW_HEIGHT = 36; + +function formatTime(ms: number): string { + if (!Number.isFinite(ms)) return "—"; + return new Date(ms).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function countByKind(activities: RunActivity[]): Record { + const counts = { + execution: 0, + graph: 0, + message: 0, + artifact: 0, + evaluation: 0, + context: 0, + sandbox: 0, + } satisfies Record; + for (const activity of activities) counts[activity.kind] += 1; + return counts; +} + +export function ActivityStackTimeline({ + activities, + mutations, + currentSequence, + selectedTaskId, + selectedActivityId, + isPlaying, + speed, + onSequenceChange, + onTogglePlay, + onSpeedChange, + onActivityClick, +}: ActivityStackTimelineProps) { + const timerRef = useRef | null>(null); + const currentSequenceRef = useRef(currentSequence); + currentSequenceRef.current = currentSequence; + + const layout = useMemo(() => stackActivities(activities), [activities]); + const counts = useMemo(() => countByKind(activities), [activities]); + const maxSequence = mutations.length > 0 ? mutations[mutations.length - 1].sequence : 0; + const minSequence = mutations.length > 0 ? mutations[0].sequence : 0; + const currentMutation = mutations.find((mutation) => mutation.sequence === currentSequence); + + const stepForward = useCallback(() => { + const idx = mutations.findIndex((mutation) => mutation.sequence === currentSequenceRef.current); + if (idx >= 0 && idx < mutations.length - 1) { + onSequenceChange(mutations[idx + 1].sequence); + } + }, [mutations, onSequenceChange]); + + const stepBack = useCallback(() => { + const idx = mutations.findIndex((mutation) => mutation.sequence === currentSequenceRef.current); + if (idx > 0) { + onSequenceChange(mutations[idx - 1].sequence); + } + }, [mutations, onSequenceChange]); + + useEffect(() => { + if (!isPlaying || mutations.length === 0) return; + + const scheduleNext = () => { + const idx = mutations.findIndex((mutation) => mutation.sequence === currentSequenceRef.current); + if (idx < 0 || idx >= mutations.length - 1) { + onTogglePlay(); + return; + } + const currentTime = Date.parse(mutations[idx].created_at); + const nextTime = Date.parse(mutations[idx + 1].created_at); + const rawDelay = (nextTime - currentTime) / speed; + const delayMs = Math.max(MIN_DELAY_MS, Math.min(MAX_DELAY_MS, rawDelay)); + timerRef.current = setTimeout(() => { + onSequenceChange(mutations[idx + 1].sequence); + scheduleNext(); + }, delayMs); + }; + + scheduleNext(); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [isPlaying, mutations, onSequenceChange, onTogglePlay, speed]); + + if (activities.length === 0) { + return ( +
+ No activity has been recorded for this run yet. +
+ ); + } + + return ( +
+
+
+
+ Activity stack +
+
+ + seq {currentMutation?.sequence ?? currentSequence} + + {formatTime(layout.startMs)} - {formatTime(layout.endMs)} + max concurrency {layout.maxConcurrency} +
+
+ +
+ + + + +
+
+ + {mutations.length > 0 && ( + onSequenceChange(Number(event.target.value))} + className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-slate-200 accent-indigo-600 dark:bg-slate-700" + aria-label="Run timeline sequence" + /> + )} + +
+ {(Object.keys(counts) as ActivityKind[]).map((kind) => ( + + {activityKindLabel(kind)} {counts[kind]} + + ))} +
+ +
+
+ {Array.from({ length: layout.rowCount }).map((_, row) => ( +
+ ))} + {layout.items.map((item) => ( +
+ +
+ ))} +
+
+
+ ); +} diff --git a/ergon-dashboard/src/features/activity/goldenFixture.test.ts b/ergon-dashboard/src/features/activity/goldenFixture.test.ts new file mode 100644 index 00000000..a1bf4515 --- /dev/null +++ b/ergon-dashboard/src/features/activity/goldenFixture.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import fixture from "../../../tests/fixtures/mas-runs/concurrent-mas-run.json"; +import { parseGraphMutationDtoArray } from "@/features/graph/contracts/graphMutations"; +import { replayToSequence } from "@/features/graph/state/graphMutationReducer"; +import { buildRunEvents } from "@/lib/runEvents"; +import { deserializeRunState } from "@/lib/runState"; +import type { WorkflowRunState } from "@/lib/types"; +import { buildRunActivities } from "./buildRunActivities"; +import { stackActivities } from "./stackLayout"; + +function emptyRunStateFrom(runState: WorkflowRunState): WorkflowRunState { + return { + ...runState, + tasks: new Map(), + resourcesByTask: new Map(), + executionsByTask: new Map(), + sandboxesByTask: new Map(), + threads: [], + contextEventsByTask: new Map(), + evaluationsByTask: new Map(), + totalTasks: 0, + totalLeafTasks: 0, + completedTasks: 0, + runningTasks: 0, + failedTasks: 0, + edges: new Map(), + annotationsByTarget: new Map(), + unhandledMutations: [], + }; +} + +test("golden concurrent fixture replays the whole graph at selected sequence and stacks overlapping activity", () => { + const runState = deserializeRunState(fixture.runState); + const mutations = parseGraphMutationDtoArray(fixture.mutations); + const checkpoint = fixture.checkpoints.find((entry) => entry.sequence === 14); + assert.ok(checkpoint); + + const displayState = replayToSequence( + mutations, + checkpoint.sequence, + emptyRunStateFrom(runState), + new Map(), + ); + const activities = buildRunActivities({ + runState, + events: buildRunEvents(runState), + mutations, + currentSequence: checkpoint.sequence, + }); + const stack = stackActivities(activities); + + assert.deepEqual( + new Set(displayState.tasks.keys()), + new Set(checkpoint.expectedTaskIds), + ); + assert.equal(stack.maxConcurrency, checkpoint.expectedMaxConcurrency); +}); diff --git a/ergon-dashboard/src/features/activity/stackLayout.test.ts b/ergon-dashboard/src/features/activity/stackLayout.test.ts new file mode 100644 index 00000000..acc0dd98 --- /dev/null +++ b/ergon-dashboard/src/features/activity/stackLayout.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RunActivity } from "./types"; +import { stackActivities } from "./stackLayout"; + +function activity( + id: string, + startAt: string, + endAt: string | null, + actor: string | null = null, +): RunActivity { + return { + id, + kind: "execution", + label: id, + taskId: id, + sequence: null, + startAt, + endAt, + isInstant: endAt === null, + actor, + sourceKind: "execution.span", + metadata: {}, + }; +} + +test("stackActivities allocates rows by time overlap and reuses rows", () => { + const layout = stackActivities([ + activity("a", "2026-04-26T12:00:00.000Z", "2026-04-26T12:00:10.000Z", "agent-a"), + activity("b", "2026-04-26T12:00:05.000Z", "2026-04-26T12:00:12.000Z", "agent-b"), + activity("c", "2026-04-26T12:00:12.000Z", "2026-04-26T12:00:15.000Z", "agent-a"), + ]); + + const rowById = new Map(layout.items.map((item) => [item.activity.id, item.row])); + + assert.equal(layout.rowCount, 2); + assert.equal(layout.maxConcurrency, 2); + assert.equal(rowById.get("a"), rowById.get("c")); + assert.notEqual(rowById.get("a"), rowById.get("b")); +}); + +test("stackActivities reports three-way concurrency and does not group by actor", () => { + const layout = stackActivities([ + activity("a", "2026-04-26T12:00:00.000Z", "2026-04-26T12:00:20.000Z", "agent-a"), + activity("b", "2026-04-26T12:00:05.000Z", "2026-04-26T12:00:21.000Z", "agent-b"), + activity("c", "2026-04-26T12:00:10.000Z", "2026-04-26T12:00:15.000Z", "agent-a"), + ]); + + const rowsForAgentA = layout.items + .filter((item) => item.activity.actor === "agent-a") + .map((item) => item.row); + + assert.equal(layout.maxConcurrency, 3); + assert.deepEqual(new Set(rowsForAgentA).size, 2); +}); + +test("stackActivities computes point-in-time concurrency instead of interval intersections", () => { + const layout = stackActivities([ + activity("long", "2026-04-26T12:00:00.000Z", "2026-04-26T12:00:30.000Z"), + activity("early", "2026-04-26T12:00:05.000Z", "2026-04-26T12:00:10.000Z"), + activity("late", "2026-04-26T12:00:20.000Z", "2026-04-26T12:00:25.000Z"), + ]); + + assert.equal(layout.maxConcurrency, 2); +}); diff --git a/ergon-dashboard/src/features/activity/stackLayout.ts b/ergon-dashboard/src/features/activity/stackLayout.ts new file mode 100644 index 00000000..e5e95967 --- /dev/null +++ b/ergon-dashboard/src/features/activity/stackLayout.ts @@ -0,0 +1,99 @@ +import type { ActivityStackLayout, RunActivity } from "./types"; + +export interface StackActivityOptions { + minMarkerWidthPct?: number; + minSpanWidthPct?: number; + markerDurationMs?: number; +} + +interface TimedActivity { + activity: RunActivity; + startMs: number; + endMs: number; +} + +const DEFAULT_MARKER_DURATION_MS = 250; + +function firstFreeRow(rowEnds: number[], startMs: number): number { + const row = rowEnds.findIndex((endMs) => endMs <= startMs); + return row === -1 ? rowEnds.length : row; +} + +function parseTime(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toTimedActivity( + activity: RunActivity, + markerDurationMs: number, +): TimedActivity { + const startMs = parseTime(activity.startAt); + const rawEndMs = activity.endAt ? parseTime(activity.endAt) : startMs; + const endMs = + activity.isInstant || rawEndMs <= startMs + ? startMs + markerDurationMs + : rawEndMs; + return { activity, startMs, endMs }; +} + +function computeMaxSpanConcurrency(timed: TimedActivity[]): number { + const events = timed + .filter((item) => !item.activity.isInstant) + .flatMap((item) => [ + { at: item.startMs, delta: 1 }, + { at: item.endMs, delta: -1 }, + ]) + .sort((a, b) => a.at - b.at || a.delta - b.delta); + if (events.length === 0) return 0; + let max = 0; + let active = 0; + for (const event of events) { + active += event.delta; + max = Math.max(max, active); + } + return max; +} + +export function stackActivities( + activities: RunActivity[], + options: StackActivityOptions = {}, +): ActivityStackLayout { + const minMarkerWidthPct = options.minMarkerWidthPct ?? 0.35; + const minSpanWidthPct = options.minSpanWidthPct ?? 0.75; + const markerDurationMs = options.markerDurationMs ?? DEFAULT_MARKER_DURATION_MS; + const timed = activities + .map((activity) => toTimedActivity(activity, markerDurationMs)) + .sort( + (a, b) => + a.startMs - b.startMs || + a.endMs - b.endMs || + a.activity.id.localeCompare(b.activity.id), + ); + + if (timed.length === 0) { + return { items: [], rowCount: 0, startMs: 0, endMs: 0, maxConcurrency: 0 }; + } + + const startMs = Math.min(...timed.map((item) => item.startMs)); + const endMs = Math.max(...timed.map((item) => item.endMs)); + const spanMs = Math.max(1, endMs - startMs); + const rowEnds: number[] = []; + + const items = timed.map(({ activity, startMs: itemStartMs, endMs: itemEndMs }) => { + const row = firstFreeRow(rowEnds, itemStartMs); + rowEnds[row] = itemEndMs; + + const leftPct = ((itemStartMs - startMs) / spanMs) * 100; + const rawWidthPct = ((itemEndMs - itemStartMs) / spanMs) * 100; + const widthPct = activity.isInstant + ? Math.max(minMarkerWidthPct, rawWidthPct) + : Math.max(minSpanWidthPct, rawWidthPct); + + return { activity, row, leftPct, widthPct }; + }); + + const maxConcurrency = computeMaxSpanConcurrency(timed); + + return { items, rowCount: rowEnds.length, startMs, endMs, maxConcurrency }; +} diff --git a/ergon-dashboard/src/features/activity/types.ts b/ergon-dashboard/src/features/activity/types.ts new file mode 100644 index 00000000..e4fa7336 --- /dev/null +++ b/ergon-dashboard/src/features/activity/types.ts @@ -0,0 +1,39 @@ +import type { RunEventKind } from "@/lib/runEvents"; + +export type ActivityKind = + | "execution" + | "graph" + | "message" + | "artifact" + | "evaluation" + | "context" + | "sandbox"; + +export interface RunActivity { + id: string; + kind: ActivityKind; + label: string; + taskId: string | null; + sequence: number | null; + startAt: string; + endAt: string | null; + isInstant: boolean; + actor: string | null; + sourceKind: RunEventKind | "execution.span" | "sandbox.span" | "graph.mutation"; + metadata: Record; +} + +export interface ActivityStackItem { + activity: RunActivity; + row: number; + leftPct: number; + widthPct: number; +} + +export interface ActivityStackLayout { + items: ActivityStackItem[]; + rowCount: number; + startMs: number; + endMs: number; + maxConcurrency: number; +} diff --git a/ergon-dashboard/src/features/graph/components/ContainerNode.tsx b/ergon-dashboard/src/features/graph/components/ContainerNode.tsx index dc580d46..c3214029 100644 --- a/ergon-dashboard/src/features/graph/components/ContainerNode.tsx +++ b/ergon-dashboard/src/features/graph/components/ContainerNode.tsx @@ -15,6 +15,7 @@ interface ContainerNodeProps { onClick?: (taskId: string) => void; selected?: boolean; dimmed?: boolean; + highlighted?: boolean; containerWidth: number; containerHeight: number; layoutDirection?: "TB" | "LR"; @@ -28,6 +29,7 @@ function ContainerNodeComponent({ onClick, selected = false, dimmed = false, + highlighted = false, containerWidth, containerHeight, layoutDirection = "LR", @@ -63,6 +65,7 @@ function ContainerNodeComponent({ bg-gray-50/40 dark:bg-gray-900/40 ${borderColor} ${selected ? "ring-2 ring-offset-2 ring-indigo-500 dark:ring-indigo-400" : ""} + ${highlighted ? "ring-2 ring-offset-2 ring-blue-500 dark:ring-blue-400" : ""} ${dimmed ? "opacity-30" : ""} ${task.status === ("cancelled" as TaskStatus) ? "bg-gray-50/50 dark:bg-gray-900/30" : ""} `} diff --git a/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts b/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts new file mode 100644 index 00000000..bd638469 --- /dev/null +++ b/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Node } from "@xyflow/react"; + +import fixture from "../../../../tests/fixtures/mas-runs/concurrent-mas-run.json"; +import { parseGraphMutationDtoArray } from "@/features/graph/contracts/graphMutations"; +import { replayToSequence } from "@/features/graph/state/graphMutationReducer"; +import { deserializeRunState } from "@/lib/runState"; +import type { WorkflowRunState } from "@/lib/types"; +import { calculateExpandedContainers, computeHierarchicalLayout } from "./hierarchicalLayout"; +import { NODE_VARIANTS, getNodeVariant } from "./layoutTypes"; + +interface Rect { + id: string; + parentId: string | undefined; + x: number; + y: number; + width: number; + height: number; +} + +function emptyRunStateFrom(runState: WorkflowRunState): WorkflowRunState { + return { + ...runState, + tasks: new Map(), + totalTasks: 0, + totalLeafTasks: 0, + completedTasks: 0, + runningTasks: 0, + failedTasks: 0, + edges: new Map(), + annotationsByTarget: new Map(), + unhandledMutations: [], + }; +} + +function rectFor(node: Node): Rect { + const task = (node.data as { task?: { level: number } }).task; + const variant = getNodeVariant(task?.level ?? 1); + const style = node.style as { width?: number; height?: number } | undefined; + return { + id: node.id, + parentId: node.parentId, + x: node.position.x, + y: node.position.y, + width: Number(style?.width ?? NODE_VARIANTS[variant].width), + height: Number(style?.height ?? NODE_VARIANTS[variant].height), + }; +} + +function overlaps(a: Rect, b: Rect): boolean { + return ( + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y + ); +} + +function overlappingSiblingPairs(nodes: Node[]): Array<[string, string]> { + const rects = nodes.map(rectFor); + const pairs: Array<[string, string]> = []; + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + if (rects[i].parentId !== rects[j].parentId) continue; + if (overlaps(rects[i], rects[j])) pairs.push([rects[i].id, rects[j].id]); + } + } + return pairs; +} + +test("golden layout renders the full recursive graph without overlapping sibling boxes", () => { + const runState = deserializeRunState(fixture.runState); + const mutations = parseGraphMutationDtoArray(fixture.mutations); + const checkpoint = fixture.checkpoints.find((entry) => entry.sequence === 14); + assert.ok(checkpoint); + const displayState = replayToSequence( + mutations, + checkpoint.sequence, + emptyRunStateFrom(runState), + new Map(), + ); + const result = computeHierarchicalLayout( + displayState.tasks, + calculateExpandedContainers(displayState.tasks, Infinity), + "", + undefined, + null, + "LR", + new Set(), + ); + + assert.deepEqual(new Set(result.nodes.map((node) => node.id)), new Set(checkpoint.expectedTaskIds)); + assert.deepEqual(overlappingSiblingPairs(result.nodes), []); +}); diff --git a/ergon-dashboard/src/features/graph/layout/hierarchicalLayout.ts b/ergon-dashboard/src/features/graph/layout/hierarchicalLayout.ts index 631d8ba3..1fec3c23 100644 --- a/ergon-dashboard/src/features/graph/layout/hierarchicalLayout.ts +++ b/ergon-dashboard/src/features/graph/layout/hierarchicalLayout.ts @@ -239,6 +239,7 @@ export function computeHierarchicalLayout( selectedTaskId?: string | null, direction: "TB" | "LR" = "LR", newNodeIds: ReadonlySet = new Set(), + highlightedTaskIds: ReadonlySet = new Set(), ): LayoutedGraph { const containerDimensions = new Map(); const allNodes: TaskNodeType[] = []; @@ -340,6 +341,7 @@ export function computeHierarchicalLayout( const localPos = localPositions.get(cid) ?? { x: 0, y: 0 }; const isMatch = !searchLower || matchingNodeIds.has(cid); + const childContainerDimensions = containerDimensions.get(cid); allNodes.push({ id: cid, type: "taskNode", @@ -354,11 +356,19 @@ export function computeHierarchicalLayout( onClick: onTaskClick, selected: cid === selectedTaskId, dimmed: searchLower ? !isMatch : false, - highlighted: searchLower ? isMatch : false, + highlighted: (searchLower ? isMatch : false) || highlightedTaskIds.has(cid), isNew: newNodeIds.has(cid), maxGraphDepth, graphLayoutDirection: direction, }, + ...(expandedContainers.has(cid) && childContainerDimensions + ? { + style: { + width: childContainerDimensions.width, + height: childContainerDimensions.height, + }, + } + : {}), }); } @@ -461,7 +471,7 @@ export function computeHierarchicalLayout( onClick: onTaskClick, selected: taskId === selectedTaskId, dimmed: searchLower ? !isMatch : false, - highlighted: searchLower ? isMatch : false, + highlighted: (searchLower ? isMatch : false) || highlightedTaskIds.has(taskId), isNew: newNodeIds.has(taskId), maxGraphDepth, graphLayoutDirection: direction, diff --git a/ergon-dashboard/src/lib/testing/dashboardHarness.ts b/ergon-dashboard/src/lib/testing/dashboardHarness.ts index 663ce60b..16955fcb 100644 --- a/ergon-dashboard/src/lib/testing/dashboardHarness.ts +++ b/ergon-dashboard/src/lib/testing/dashboardHarness.ts @@ -26,6 +26,7 @@ declare global { | { cohorts: CohortSummary[]; cohortDetails: Record; + mutationsByRun: Record; } | undefined; } @@ -34,6 +35,7 @@ export interface DashboardHarnessSeedPayload { cohorts?: CohortSummary[]; cohortDetails?: Record; runs?: SerializedWorkflowRunState[]; + mutations?: Record; } function getHarnessState() { @@ -41,6 +43,7 @@ function getHarnessState() { global.__dashboardHarness = { cohorts: [], cohortDetails: {}, + mutationsByRun: {}, }; } return global.__dashboardHarness; @@ -58,6 +61,7 @@ export function resetDashboardHarness(): void { const harness = getHarnessState(); harness.cohorts = []; harness.cohortDetails = {}; + harness.mutationsByRun = {}; } export function seedDashboardHarness(payload: DashboardHarnessSeedPayload): void { @@ -67,6 +71,7 @@ export function seedDashboardHarness(payload: DashboardHarnessSeedPayload): void const harness = getHarnessState(); harness.cohorts = payload.cohorts ?? []; harness.cohortDetails = payload.cohortDetails ?? {}; + harness.mutationsByRun = payload.mutations ?? {}; for (const run of payload.runs ?? []) { store.seedRun(deserializeRunState(run)); @@ -120,6 +125,11 @@ export function getHarnessRun(runId: string): SerializedWorkflowRunState | null return run ? serializeRunState(run) : null; } +export function getHarnessRunMutations(runId: string): unknown[] | null { + requireHarnessEnabled(); + return getHarnessState().mutationsByRun[runId] ?? null; +} + export function emitHarnessRunCompleted(data: { runId: string; status: "completed" | "failed"; diff --git a/ergon-dashboard/tests/e2e/_shared/smoke.ts b/ergon-dashboard/tests/e2e/_shared/smoke.ts index a0c8e337..8e048809 100644 --- a/ergon-dashboard/tests/e2e/_shared/smoke.ts +++ b/ergon-dashboard/tests/e2e/_shared/smoke.ts @@ -54,6 +54,11 @@ async function screenshot(target: Page, out: string): Promise { await target.screenshot({ path: out, fullPage: true }); } +async function locatorScreenshot(target: Locator, out: string): Promise { + await fs.mkdir(path.dirname(out), { recursive: true }); + await target.screenshot({ path: out }); +} + function graphElementForTask(page: Page, taskId: string): Locator { return page .locator( @@ -115,6 +120,8 @@ async function assertRunWorkspace( await expect(page.getByTestId("run-status-bar")).toBeVisible(); await expect(page.getByTestId("run-status-count-completed")).toBeVisible(); await expect(page.getByTestId("graph-canvas")).toBeVisible(); + await expect(page.getByTestId("activity-stack-region")).toBeVisible(); + await expect(page.locator('[data-testid^="activity-bar-"]').first()).toBeVisible(); const evaluatedTaskIds = new Set(state.evaluations.map((evaluation) => evaluation.task_id)); const selected = await selectRenderedGraphTask(page, state, runId, evaluatedTaskIds); @@ -145,6 +152,7 @@ async function assertRunWorkspace( if (state.mutation_count > 0) { await page.getByTestId("mode-timeline").click(); await expect(page.getByTestId("timeline-region")).toBeVisible(); + await expect(page.getByTestId("activity-current-sequence")).toContainText(/seq/i); } } @@ -201,6 +209,14 @@ export function defineSmokeSpec(cfg: SmokeSpecConfig): void { page, path.join(screenshotDir, cfg.env, `${run_id}-happy.png`), ); + await screenshot( + page, + path.join(screenshotDir, cfg.env, `${run_id}-visual-debugger-full.png`), + ); + await locatorScreenshot( + page.getByTestId("activity-stack-region"), + path.join(screenshotDir, cfg.env, `${run_id}-activity-stack.png`), + ); if (cfg.extraRunAssertions) { await cfg.extraRunAssertions(page, run_id); @@ -232,6 +248,14 @@ export function defineSmokeSpec(cfg: SmokeSpecConfig): void { page, path.join(screenshotDir, cfg.env, `${run_id}-sad.png`), ); + await screenshot( + page, + path.join(screenshotDir, cfg.env, `${run_id}-visual-debugger-full.png`), + ); + await locatorScreenshot( + page.getByTestId("activity-stack-region"), + path.join(screenshotDir, cfg.env, `${run_id}-activity-stack.png`), + ); }); } diff --git a/ergon-dashboard/tests/e2e/activity-stack.spec.ts b/ergon-dashboard/tests/e2e/activity-stack.spec.ts new file mode 100644 index 00000000..39ee9184 --- /dev/null +++ b/ergon-dashboard/tests/e2e/activity-stack.spec.ts @@ -0,0 +1,108 @@ +import { expect, Page, test } from "@playwright/test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { + CONCURRENT_MAS_FIXTURE_IDS, + createConcurrentMasDashboardSeed, +} from "../helpers/dashboardFixtures"; +import { resetHarness, seedHarness } from "../helpers/harnessClient"; + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +test.beforeEach(async ({ request }) => { + await resetHarness(request); + await seedHarness(request, createConcurrentMasDashboardSeed()); +}); + +function boxesOverlap(a: Box, b: Box, tolerancePx = 2): boolean { + return ( + a.x + tolerancePx < b.x + b.width && + a.x + a.width > b.x + tolerancePx && + a.y + tolerancePx < b.y + b.height && + a.y + a.height > b.y + tolerancePx + ); +} + +async function overlappingPairsFor(page: Page, selector: string): Promise<[number, number][]> { + const boxes = await page.locator(selector).evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }), + ); + const pairs: [number, number][] = []; + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + if (boxesOverlap(boxes[i], boxes[j])) pairs.push([i, j]); + } + } + return pairs; +} + +async function dumpScreenshots(page: Page) { + if (process.env.VISUAL_DEBUGGER_SCREENSHOTS !== "1") return; + const outDir = path.join(process.cwd(), "tmp", "visual-debugger"); + await fs.mkdir(outDir, { recursive: true }); + await page.screenshot({ path: path.join(outDir, "run-full.png"), fullPage: true }); + await page.getByTestId("activity-stack-region").screenshot({ + path: path.join(outDir, "activity-stack.png"), + }); + await page.getByTestId("workspace-region").screenshot({ + path: path.join(outDir, "workspace-open.png"), + }); +} + +async function dumpGraphScreenshot(page: Page) { + if (process.env.VISUAL_DEBUGGER_SCREENSHOTS !== "1") return; + const outDir = path.join(process.cwd(), "tmp", "visual-debugger"); + await fs.mkdir(outDir, { recursive: true }); + await page.getByTestId("graph-canvas").screenshot({ + path: path.join(outDir, "graph-canvas.png"), + }); +} + +test("visual debugger renders graph, activity stack, and time-aware workspace", async ({ page }) => { + await page.goto( + `/cohorts/${CONCURRENT_MAS_FIXTURE_IDS.cohortId}/runs/${CONCURRENT_MAS_FIXTURE_IDS.runId}`, + ); + + await expect(page.getByTestId("run-header")).toBeVisible(); + await expect(page.getByTestId("graph-canvas")).toBeVisible(); + await expect(page.getByTestId("activity-stack-region")).toBeVisible(); + expect(await page.getByTestId("activity-stack-row").count()).toBeGreaterThan(1); + await expect + .poll( + async () => + ( + await overlappingPairsFor( + page, + '.react-flow__node:has([data-testid^="graph-node-"])', + ) + ).length, + { timeout: 5000 }, + ) + .toBe(0); + await dumpGraphScreenshot(page); + + await page.getByTestId("mode-timeline").click(); + await expect(page.getByTestId("activity-current-sequence")).toContainText("seq 14"); + await expect(page.getByText("Graph 18")).toBeVisible(); + + const firstExecution = page + .locator('[data-testid^="activity-bar-"][data-kind="execution"]') + .first(); + await expect(firstExecution).toBeVisible(); + await firstExecution.click(); + + await expect(page.getByTestId("workspace-region")).toBeVisible(); + await expect(page.getByTestId("workspace-header")).toBeVisible(); + await expect(page.getByTestId("workspace-timeline-badge")).toContainText("seq"); + + await dumpScreenshots(page); +}); diff --git a/ergon-dashboard/tests/e2e/run.snapshot.spec.ts b/ergon-dashboard/tests/e2e/run.snapshot.spec.ts index 47c0279e..7db058f6 100644 --- a/ergon-dashboard/tests/e2e/run.snapshot.spec.ts +++ b/ergon-dashboard/tests/e2e/run.snapshot.spec.ts @@ -14,7 +14,7 @@ test("run page keeps cohort breadcrumb context", async ({ page }) => { await expect(page.getByTestId("run-breadcrumb-cohort")).toContainText( "minif2f-react-worker-gpt5v3", ); - await expect(page.getByTestId("run-header")).toContainText("amc12a_2008_p25"); + await expect(page.getByTestId("run-header")).toContainText("parallel"); }); test("graph selection opens workspace evidence sections", async ({ page }) => { diff --git a/ergon-dashboard/tests/fixtures/mas-runs/README.md b/ergon-dashboard/tests/fixtures/mas-runs/README.md new file mode 100644 index 00000000..3057be6a --- /dev/null +++ b/ergon-dashboard/tests/fixtures/mas-runs/README.md @@ -0,0 +1,5 @@ +# MAS Run Fixtures + +These fixtures are small, sanitized run snapshots used by frontend semantic layout tests and local visual review. + +They intentionally avoid model outputs, secrets, and large resources. Keep them reviewable: add only the task graph, timestamps, mutations, and evidence needed to prove visual debugger behavior. diff --git a/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json b/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json new file mode 100644 index 00000000..7b6e08a9 --- /dev/null +++ b/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json @@ -0,0 +1,573 @@ +{ + "name": "concurrent-mas-run", + "runState": { + "id": "99999999-9999-4999-8999-999999999999", + "experimentId": "33333333-3333-4333-8333-333333333333", + "name": "Concurrent MAS run", + "status": "executing", + "rootTaskId": "10000000-0000-4000-8000-000000000001", + "startedAt": "2026-04-26T12:00:00.000Z", + "completedAt": null, + "durationSeconds": null, + "totalTasks": 6, + "totalLeafTasks": 4, + "completedTasks": 1, + "runningTasks": 3, + "failedTasks": 0, + "finalScore": null, + "error": null, + "tasks": { + "10000000-0000-4000-8000-000000000001": { + "id": "10000000-0000-4000-8000-000000000001", + "name": "Root investigation", + "description": "Coordinate a multi-agent research pass.", + "status": "running", + "parentId": null, + "childIds": [ + "10000000-0000-4000-8000-000000000002", + "10000000-0000-4000-8000-000000000003", + "10000000-0000-4000-8000-000000000004" + ], + "dependsOnIds": [], + "isLeaf": false, + "level": 0, + "assignedWorkerId": null, + "assignedWorkerName": "planner", + "startedAt": "2026-04-26T12:00:00.000Z", + "completedAt": null + }, + "10000000-0000-4000-8000-000000000002": { + "id": "10000000-0000-4000-8000-000000000002", + "name": "Search literature", + "description": "Find candidate references.", + "status": "running", + "parentId": "10000000-0000-4000-8000-000000000001", + "childIds": [], + "dependsOnIds": [], + "isLeaf": true, + "level": 1, + "assignedWorkerId": null, + "assignedWorkerName": "researcher-a", + "startedAt": "2026-04-26T12:00:05.000Z", + "completedAt": null + }, + "10000000-0000-4000-8000-000000000003": { + "id": "10000000-0000-4000-8000-000000000003", + "name": "Check claims", + "description": "Verify extracted claims.", + "status": "running", + "parentId": "10000000-0000-4000-8000-000000000001", + "childIds": [], + "dependsOnIds": [], + "isLeaf": true, + "level": 1, + "assignedWorkerId": null, + "assignedWorkerName": "researcher-b", + "startedAt": "2026-04-26T12:00:08.000Z", + "completedAt": null + }, + "10000000-0000-4000-8000-000000000004": { + "id": "10000000-0000-4000-8000-000000000004", + "name": "Synthesize answer", + "description": "Write the final synthesis after evidence is available.", + "status": "pending", + "parentId": "10000000-0000-4000-8000-000000000001", + "childIds": [ + "10000000-0000-4000-8000-000000000005", + "10000000-0000-4000-8000-000000000006" + ], + "dependsOnIds": [ + "10000000-0000-4000-8000-000000000002", + "10000000-0000-4000-8000-000000000003" + ], + "isLeaf": false, + "level": 1, + "assignedWorkerId": null, + "assignedWorkerName": "writer", + "startedAt": null, + "completedAt": null + }, + "10000000-0000-4000-8000-000000000005": { + "id": "10000000-0000-4000-8000-000000000005", + "name": "Draft narrative", + "description": "Draft the explanation.", + "status": "pending", + "parentId": "10000000-0000-4000-8000-000000000004", + "childIds": [], + "dependsOnIds": [], + "isLeaf": true, + "level": 2, + "assignedWorkerId": null, + "assignedWorkerName": "writer-a", + "startedAt": null, + "completedAt": null + }, + "10000000-0000-4000-8000-000000000006": { + "id": "10000000-0000-4000-8000-000000000006", + "name": "Validate citations", + "description": "Validate citation coverage.", + "status": "completed", + "parentId": "10000000-0000-4000-8000-000000000004", + "childIds": [], + "dependsOnIds": [], + "isLeaf": true, + "level": 2, + "assignedWorkerId": null, + "assignedWorkerName": "writer-b", + "startedAt": "2026-04-26T12:00:18.000Z", + "completedAt": "2026-04-26T12:00:25.000Z" + } + }, + "resourcesByTask": { + "10000000-0000-4000-8000-000000000002": [ + { + "id": "20000000-0000-4000-8000-000000000001", + "taskId": "10000000-0000-4000-8000-000000000002", + "taskExecutionId": "30000000-0000-4000-8000-000000000001", + "name": "references.md", + "mimeType": "text/markdown", + "filePath": "/workspace/references.md", + "sizeBytes": 1024, + "createdAt": "2026-04-26T12:00:22.000Z" + } + ] + }, + "executionsByTask": { + "10000000-0000-4000-8000-000000000002": [ + { + "id": "30000000-0000-4000-8000-000000000001", + "taskId": "10000000-0000-4000-8000-000000000002", + "attemptNumber": 1, + "status": "running", + "startedAt": "2026-04-26T12:00:05.000Z", + "completedAt": "2026-04-26T12:00:24.000Z", + "finalAssistantMessage": "Found candidate references.", + "errorMessage": null, + "score": null, + "agentId": "agent-a", + "agentName": "researcher-a", + "evaluationDetails": {}, + "outputResourceIds": [ + "20000000-0000-4000-8000-000000000001" + ] + } + ], + "10000000-0000-4000-8000-000000000003": [ + { + "id": "30000000-0000-4000-8000-000000000002", + "taskId": "10000000-0000-4000-8000-000000000003", + "attemptNumber": 1, + "status": "running", + "startedAt": "2026-04-26T12:00:08.000Z", + "completedAt": "2026-04-26T12:00:21.000Z", + "finalAssistantMessage": "Claims verified.", + "errorMessage": null, + "score": null, + "agentId": "agent-b", + "agentName": "researcher-b", + "evaluationDetails": {}, + "outputResourceIds": [] + } + ], + "10000000-0000-4000-8000-000000000006": [ + { + "id": "30000000-0000-4000-8000-000000000003", + "taskId": "10000000-0000-4000-8000-000000000006", + "attemptNumber": 1, + "status": "completed", + "startedAt": "2026-04-26T12:00:18.000Z", + "completedAt": "2026-04-26T12:00:25.000Z", + "finalAssistantMessage": "Citation check passed.", + "errorMessage": null, + "score": null, + "agentId": "agent-c", + "agentName": "writer-b", + "evaluationDetails": {}, + "outputResourceIds": [] + } + ] + }, + "sandboxesByTask": { + "10000000-0000-4000-8000-000000000002": { + "sandboxId": "sandbox-search", + "taskId": "10000000-0000-4000-8000-000000000002", + "template": "research", + "timeoutMinutes": 30, + "status": "closed", + "createdAt": "2026-04-26T12:00:04.000Z", + "closedAt": "2026-04-26T12:00:26.000Z", + "closeReason": "completed", + "commands": [ + { + "command": "python search.py", + "stdout": "ok", + "stderr": null, + "exitCode": 0, + "durationMs": 2000, + "timestamp": "2026-04-26T12:00:12.000Z" + } + ] + } + }, + "threads": [ + { + "id": "40000000-0000-4000-8000-000000000001", + "runId": "99999999-9999-4999-8999-999999999999", + "taskId": "10000000-0000-4000-8000-000000000003", + "topic": "task_clarification", + "agentAId": "agent-b", + "agentBId": "stakeholder", + "createdAt": "2026-04-26T12:00:09.000Z", + "updatedAt": "2026-04-26T12:00:16.000Z", + "messages": [ + { + "id": "40000000-0000-4000-8000-000000000002", + "threadId": "40000000-0000-4000-8000-000000000001", + "threadTopic": "task_clarification", + "runId": "99999999-9999-4999-8999-999999999999", + "taskId": "10000000-0000-4000-8000-000000000003", + "taskExecutionId": "30000000-0000-4000-8000-000000000002", + "fromAgentId": "agent-b", + "toAgentId": "stakeholder", + "content": "Should I reject ungrounded claims?", + "sequenceNum": 0, + "createdAt": "2026-04-26T12:00:16.000Z" + } + ] + } + ], + "evaluationsByTask": { + "10000000-0000-4000-8000-000000000006": { + "id": "50000000-0000-4000-8000-000000000001", + "runId": "99999999-9999-4999-8999-999999999999", + "taskId": "10000000-0000-4000-8000-000000000006", + "totalScore": 1, + "maxScore": 1, + "normalizedScore": 1, + "stagesEvaluated": 1, + "stagesPassed": 1, + "failedGate": null, + "createdAt": "2026-04-26T12:00:27.000Z", + "criterionResults": [ + { + "id": "50000000-0000-4000-8000-000000000002", + "stageNum": 0, + "stageName": "citation_validation", + "criterionNum": 0, + "criterionType": "code_rule", + "criterionDescription": "Citations validate", + "evaluationInput": null, + "score": 1, + "maxScore": 1, + "feedback": "ok", + "evaluatedActionIds": [], + "evaluatedResourceIds": [], + "error": null + } + ] + } + }, + "contextEventsByTask": { + "10000000-0000-4000-8000-000000000002": [ + { + "id": "60000000-0000-4000-8000-000000000001", + "taskExecutionId": "30000000-0000-4000-8000-000000000001", + "taskNodeId": "10000000-0000-4000-8000-000000000002", + "workerBindingKey": "researcher-a", + "sequence": 0, + "eventType": "tool_call", + "payload": { + "event_type": "tool_call", + "tool_name": "search", + "args": { + "query": "MAS layout" + } + }, + "createdAt": "2026-04-26T12:00:10.000Z", + "startedAt": "2026-04-26T12:00:10.000Z", + "completedAt": "2026-04-26T12:00:14.000Z" + } + ] + } + }, + "mutations": [ + { + "id": "70000000-0000-4000-8000-000000000001", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 1, + "mutation_type": "node.added", + "target_type": "node", + "target_id": "10000000-0000-4000-8000-000000000001", + "actor": "planner", + "old_value": null, + "new_value": { + "task_slug": "Root investigation", + "instance_key": "root", + "description": "Coordinate a multi-agent research pass.", + "status": "pending", + "assigned_worker_slug": "planner" + }, + "reason": "workflow started", + "created_at": "2026-04-26T12:00:00.000Z" + }, + { + "id": "70000000-0000-4000-8000-000000000002", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 2, + "mutation_type": "node.status_changed", + "target_type": "node", + "target_id": "10000000-0000-4000-8000-000000000001", + "actor": "planner", + "old_value": { + "status": "pending" + }, + "new_value": { + "status": "running" + }, + "reason": "worker started", + "created_at": "2026-04-26T12:00:00.000Z" + }, + { + "id": "70000000-0000-4000-8000-000000000003", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 3, + "mutation_type": "node.added", + "target_type": "node", + "target_id": "10000000-0000-4000-8000-000000000002", + "actor": "planner", + "old_value": null, + "new_value": { + "task_slug": "Search literature", + "instance_key": "search", + "description": "Find candidate references.", + "status": "pending", + "assigned_worker_slug": "researcher-a" + }, + "reason": "delegate", + "created_at": "2026-04-26T12:00:03.000Z" + }, + { + "id": "70000000-0000-4000-8000-000000000004", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 4, + "mutation_type": "edge.added", + "target_type": "edge", + "target_id": "70000000-0000-4000-8000-100000000004", + "actor": "planner", + "old_value": null, + "new_value": { + "source_node_id": "10000000-0000-4000-8000-000000000001", + "target_node_id": "10000000-0000-4000-8000-000000000002", + "status": "active" + }, + "reason": "parent-child", + "created_at": "2026-04-26T12:00:03.100Z" + }, + { + "id": "70000000-0000-4000-8000-000000000005", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 5, + "mutation_type": "node.added", + "target_type": "node", + "target_id": "10000000-0000-4000-8000-000000000003", + "actor": "planner", + "old_value": null, + "new_value": { + "task_slug": "Check claims", + "instance_key": "check", + "description": "Verify extracted claims.", + "status": "pending", + "assigned_worker_slug": "researcher-b" + }, + "reason": "delegate", + "created_at": "2026-04-26T12:00:04.000Z" + }, + { + "id": "70000000-0000-4000-8000-000000000006", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 6, + "mutation_type": "edge.added", + "target_type": "edge", + "target_id": "70000000-0000-4000-8000-100000000006", + "actor": "planner", + "old_value": null, + "new_value": { + "source_node_id": "10000000-0000-4000-8000-000000000001", + "target_node_id": "10000000-0000-4000-8000-000000000003", + "status": "active" + }, + "reason": "parent-child", + "created_at": "2026-04-26T12:00:04.100Z" + }, + { + "id": "70000000-0000-4000-8000-000000000007", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 7, + "mutation_type": "node.added", + "target_type": "node", + "target_id": "10000000-0000-4000-8000-000000000004", + "actor": "planner", + "old_value": null, + "new_value": { + "task_slug": "Synthesize answer", + "instance_key": "synth", + "description": "Write the final synthesis after evidence is available.", + "status": "pending", + "assigned_worker_slug": "writer" + }, + "reason": "delegate", + "created_at": "2026-04-26T12:00:05.000Z" + }, + { + "id": "70000000-0000-4000-8000-000000000008", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 8, + "mutation_type": "edge.added", + "target_type": "edge", + "target_id": "70000000-0000-4000-8000-100000000008", + "actor": "planner", + "old_value": null, + "new_value": { + "source_node_id": "10000000-0000-4000-8000-000000000001", + "target_node_id": "10000000-0000-4000-8000-000000000004", + "status": "active" + }, + "reason": "parent-child", + "created_at": "2026-04-26T12:00:05.100Z" + }, + { + "id": "70000000-0000-4000-8000-000000000009", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 9, + "mutation_type": "node.status_changed", + "target_type": "node", + "target_id": "10000000-0000-4000-8000-000000000002", + "actor": "researcher-a", + "old_value": { + "status": "pending" + }, + "new_value": { + "status": "running" + }, + "reason": "worker started", + "created_at": "2026-04-26T12:00:05.000Z" + }, + { + "id": "70000000-0000-4000-8000-000000000010", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 10, + "mutation_type": "node.status_changed", + "target_type": "node", + "target_id": "10000000-0000-4000-8000-000000000003", + "actor": "researcher-b", + "old_value": { + "status": "pending" + }, + "new_value": { + "status": "running" + }, + "reason": "worker started", + "created_at": "2026-04-26T12:00:08.000Z" + }, + { + "id": "70000000-0000-4000-8000-000000000011", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 11, + "mutation_type": "node.added", + "target_type": "node", + "target_id": "10000000-0000-4000-8000-000000000005", + "actor": "writer", + "old_value": null, + "new_value": { + "task_slug": "Draft narrative", + "instance_key": "draft", + "description": "Draft the explanation.", + "status": "pending", + "assigned_worker_slug": "writer-a" + }, + "reason": "delegate nested", + "created_at": "2026-04-26T12:00:12.000Z" + }, + { + "id": "70000000-0000-4000-8000-000000000012", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 12, + "mutation_type": "edge.added", + "target_type": "edge", + "target_id": "70000000-0000-4000-8000-100000000012", + "actor": "writer", + "old_value": null, + "new_value": { + "source_node_id": "10000000-0000-4000-8000-000000000004", + "target_node_id": "10000000-0000-4000-8000-000000000005", + "status": "active" + }, + "reason": "parent-child", + "created_at": "2026-04-26T12:00:12.100Z" + }, + { + "id": "70000000-0000-4000-8000-000000000013", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 13, + "mutation_type": "node.added", + "target_type": "node", + "target_id": "10000000-0000-4000-8000-000000000006", + "actor": "writer", + "old_value": null, + "new_value": { + "task_slug": "Validate citations", + "instance_key": "validate", + "description": "Validate citation coverage.", + "status": "pending", + "assigned_worker_slug": "writer-b" + }, + "reason": "delegate nested", + "created_at": "2026-04-26T12:00:13.000Z" + }, + { + "id": "70000000-0000-4000-8000-000000000014", + "run_id": "99999999-9999-4999-8999-999999999999", + "sequence": 14, + "mutation_type": "edge.added", + "target_type": "edge", + "target_id": "70000000-0000-4000-8000-100000000014", + "actor": "writer", + "old_value": null, + "new_value": { + "source_node_id": "10000000-0000-4000-8000-000000000004", + "target_node_id": "10000000-0000-4000-8000-000000000006", + "status": "active" + }, + "reason": "parent-child", + "created_at": "2026-04-26T12:00:13.100Z" + } + ], + "checkpoints": [ + { + "sequence": 10, + "expectedTaskIds": [ + "10000000-0000-4000-8000-000000000001", + "10000000-0000-4000-8000-000000000002", + "10000000-0000-4000-8000-000000000003", + "10000000-0000-4000-8000-000000000004" + ], + "expectedMaxConcurrency": 5, + "selectedTime": "2026-04-26T12:00:10.000Z", + "hiddenFutureResourceName": "references.md" + }, + { + "sequence": 14, + "expectedTaskIds": [ + "10000000-0000-4000-8000-000000000001", + "10000000-0000-4000-8000-000000000002", + "10000000-0000-4000-8000-000000000003", + "10000000-0000-4000-8000-000000000004", + "10000000-0000-4000-8000-000000000005", + "10000000-0000-4000-8000-000000000006" + ], + "expectedMaxConcurrency": 5, + "selectedTime": "2026-04-26T12:00:18.000Z", + "hiddenFutureResourceName": "references.md" + } + ] +} diff --git a/ergon-dashboard/tests/helpers/dashboardFixtures.ts b/ergon-dashboard/tests/helpers/dashboardFixtures.ts index 92aba500..ca91b6f5 100644 --- a/ergon-dashboard/tests/helpers/dashboardFixtures.ts +++ b/ergon-dashboard/tests/helpers/dashboardFixtures.ts @@ -1,4 +1,5 @@ import type { DashboardHarnessSeedPayload } from "../../src/lib/testing/dashboardHarness"; +import concurrentMasFixture from "../fixtures/mas-runs/concurrent-mas-run.json"; import type { CommunicationThreadState, ContextEventState, @@ -27,6 +28,14 @@ export const FIXTURE_IDS = { deltaToolCallEventId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", } as const; +export const CONCURRENT_MAS_FIXTURE_IDS = { + cohortId: "12121212-1212-4121-8121-121212121212", + experimentId: "33333333-3333-4333-8333-333333333333", + runId: "99999999-9999-4999-8999-999999999999", + searchTaskId: "10000000-0000-4000-8000-000000000002", + checkTaskId: "10000000-0000-4000-8000-000000000003", +} as const; + function taskState(task: Partial & Pick): TaskState { return { parentId: null, @@ -452,11 +461,94 @@ export function createDashboardSeed(): DashboardHarnessSeedPayload { ], }; + const concurrent = createConcurrentMasSeedOnly(); return { - cohorts: [summary], + cohorts: [summary, ...(concurrent.cohorts ?? [])], cohortDetails: { [FIXTURE_IDS.cohortId]: detail, + ...(concurrent.cohortDetails ?? {}), + }, + runs: [runState, ...(concurrent.runs ?? [])], + mutations: concurrent.mutations, + }; +} + +function createConcurrentMasSeedOnly(): DashboardHarnessSeedPayload { + const summary = { + cohort_id: CONCURRENT_MAS_FIXTURE_IDS.cohortId, + name: "concurrent-mas-visual-debugger", + description: "Deterministic concurrent MAS fixture for visual debugger tests.", + created_by: "playwright", + created_at: "2026-04-26T11:59:00.000Z", + status: "active" as const, + total_runs: 1, + status_counts: { + pending: 0, + executing: 1, + evaluating: 0, + completed: 0, + failed: 0, + }, + average_score: null, + best_score: null, + worst_score: null, + average_duration_ms: null, + failure_rate: 0, + metadata_summary: { + code_commit_sha: "visual-debugger", + repo_dirty: false, + prompt_version: "visual-debugger-fixture", + worker_version: "fixture", + model_provider: "fixture", + model_name: "fixture", + sandbox_config: { + template: "research", + timeout_minutes: 30, + }, + dispatch_config: { + scenario: "concurrent-mas", + }, + }, + stats_updated_at: "2026-04-26T12:00:30.000Z", + extras: { + benchmark_counts: { + visual_debugger: 1, + }, + latest_run_at: "2026-04-26T12:00:00.000Z", }, - runs: [runState], }; + + const detail = { + summary, + runs: [ + { + run_id: CONCURRENT_MAS_FIXTURE_IDS.runId, + definition_id: CONCURRENT_MAS_FIXTURE_IDS.experimentId, + cohort_id: CONCURRENT_MAS_FIXTURE_IDS.cohortId, + cohort_name: summary.name, + status: "executing", + created_at: "2026-04-26T11:59:30.000Z", + started_at: "2026-04-26T12:00:00.000Z", + completed_at: null, + running_time_ms: 30_000, + final_score: null, + error_message: null, + }, + ], + }; + + return { + cohorts: [summary], + cohortDetails: { + [CONCURRENT_MAS_FIXTURE_IDS.cohortId]: detail, + }, + runs: [concurrentMasFixture.runState as SerializedWorkflowRunState], + mutations: { + [CONCURRENT_MAS_FIXTURE_IDS.runId]: concurrentMasFixture.mutations, + }, + } as DashboardHarnessSeedPayload; +} + +export function createConcurrentMasDashboardSeed(): DashboardHarnessSeedPayload { + return createDashboardSeed(); } From 75d194ce0fa6105de6379306b2a756c3187b5bcb Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:52:37 +0100 Subject: [PATCH 02/66] fix(dashboard): align visual debugger styling with Claude design Made-with: Cursor --- .../mockups/mas-activity-stack-debugger.html | 780 ------------ .../mockups/mas-concurrency-debugger.html | 1092 ----------------- .../mockups/mas-sequence-debugger.html | 803 ------------ .../src/components/run/RunWorkspacePage.tsx | 142 +-- .../components/workspace/TaskWorkspace.tsx | 43 +- .../activity/components/ActivityBar.tsx | 73 +- .../components/ActivityStackTimeline.tsx | 40 +- .../graph/components/ContainerNode.tsx | 36 +- .../features/graph/components/LeafNode.tsx | 78 +- .../src/features/graph/layout/layoutTypes.ts | 14 +- 10 files changed, 212 insertions(+), 2889 deletions(-) delete mode 100644 ergon-dashboard/mockups/mas-activity-stack-debugger.html delete mode 100644 ergon-dashboard/mockups/mas-concurrency-debugger.html delete mode 100644 ergon-dashboard/mockups/mas-sequence-debugger.html diff --git a/ergon-dashboard/mockups/mas-activity-stack-debugger.html b/ergon-dashboard/mockups/mas-activity-stack-debugger.html deleted file mode 100644 index 6342d54d..00000000 --- a/ergon-dashboard/mockups/mas-activity-stack-debugger.html +++ /dev/null @@ -1,780 +0,0 @@ - - - - - - MAS Activity Stack Debugger Mockup - - - -
-
-
- ergon/researchrubrics-vanilla/run-8427-ab3f/sample 42 -
-
-
-
-

MAS Activity Stack Debugger

- Executing - Activity stack - Event tracks - Live -
-
- Cursor: sequence 42 / 119 - Bottom dock shows overlap of active work, not fixed swimlanes - Graph replayed from mutation WAL -
-
-
-
Nodes10
-
Mutations119
-
Events247
-
Active @ T6
-
-
-
- -
-
-
- Main viewer · whole recursive graph at selected sequence - Timeline selects sequence T. Graph shows every node that exists at T. -
-
-
- Snapshot @ seq 42 - Whole graph at T - Selected task highlighted -
-
- Replay contract - Future nodes are absent. Existing pending/evaluation nodes remain visible. The highlight is selection, not filtering. -
- - - - - - - - - - -
-
Diamond root · graph snapshot10 nodes visible @ seq 42
-
-
-
Planning branch2 subtasks
-
-
-
Exploration branch3 subtasks
-
-
-
Repro looplevel 2
-
-
-
Implementation branch3 subtasks
-
-
-
Evaluation branch2 subtasks
-
- -
- - running -

Diamond root

-

Coordinates the graph and propagation.

-
-
done

Plan attack

-
done

Assign workers

- -
- - completed -

Inspect repo

-

Produced candidate files and context.

-
-
- - selected -

Reproduce issue

-

Started at the selected sequence.

-
-
- - ready -

Patch strategy

-

Ready, waiting on repro output.

-
- -
done

Run failing test

-
running

Capture traceback

-
ready

Patch file

-
pending

Run tests

-
pending

Summarize

-
pending

Score output

-
pending

Persist result

- -
- J - pending -

Join + score

-

Pending join node already exists at T.

-
-
-
- - - -
-
- Bottom dock · Activity Stack - Rows are overlap layers, not fixed agent or event-kind swimlanes. -
-
-
- 21:3321:3421:3521:3621:3721:3821:3921:40 -
-
- Concurrent activity stack
- bars stack only when they overlap -
-
-
-
-
-
- -
create graph mutation burst
-
inspect repo task execution
-
message thread
-
candidate files
- -
reproduce issue running
-
ready → running
-
patch strategy waits
-
tool call
-
repro.log
- -
patch file
-
run tests
-
task eval
-
run score
- - - - -
-
-
- Color = kind - Vertical stack = overlap - Click bar = select task/span - Click instant = jump event - Toggle to event tracks for audit view -
-
- Active under cursor · seq 42 - Reproduce issue is running while patch strategy waits, a tool call fires, and the graph mutation records ready → running. This answers “what is happening concurrently right now?” -
-
-
-
-
- - diff --git a/ergon-dashboard/mockups/mas-concurrency-debugger.html b/ergon-dashboard/mockups/mas-concurrency-debugger.html deleted file mode 100644 index cac655b2..00000000 --- a/ergon-dashboard/mockups/mas-concurrency-debugger.html +++ /dev/null @@ -1,1092 +0,0 @@ - - - - - - MAS Concurrency Debugger Mockup - - - -
-
-
- ergon/researchrubrics-vanilla/run-8427-ab3f/sample 42 -
-
-
-
-

Concurrency Debugger

- Executing - Record - Live - Phase summary -
-
- Sequence cursor: 42 / 119 - Selected time: 21:38:12.408 - Snapshot mode: graph replayed to selected sequence -
-
-
-
Agents5
-
Tasks10
-
Events119
-
Score-
-
-
-
- -
-
-
- Pane 2 · Main viewer: whole recursive graph at selected sequence - Graph is replayed to time T. Bottom timeline controls time; clicking a node opens Pane 3. -
-
-
- Snapshot @ seq 42 - Whole graph at T - Recursive containers - Selected node highlighted -
-
- State at 21:38:12 - This is the full graph as it exists at sequence 42. The selected node is highlighted, but sibling branches are still visible. -
- - - - - - - - - - - - - - - -
-
- Diamond root · whole graph snapshot - 10 nodes visible @ seq 42 -
-
-
-
- Exploration branch - 3 subtasks -
-
-
-
- Planning branch - 2 subtasks -
-
-
-
- Repro loop - level 2 -
-
-
-
- Implementation branch - 3 subtasks -
-
-
-
- Evaluation branch - 2 subtasks -
-
- -
- - running -

Diamond root

-

Delegated branch work and monitors dependencies.

-
- -
- - completed -

Inspect repo

-

Produced candidate files and context.

-
- -
- - changed now -

Reproduce issue

-

Expanded because this task itself delegated a nested repro loop.

-
- -
- done -

Plan attack

-
- -
- done -

Assign workers

-
- -
- done -

Run failing test

-
- -
- running -

Capture traceback

-
- -
- - ready -

Patch strategy

-

Waiting for repro evidence.

-
- -
- ready -

Patch file

-
- -
- pending -

Run tests

-
- -
- pending -

Summarize

-
- -
- pending -

Score output

-
- -
- pending -

Persist result

-
- -
- - ancestor view -

Join + score

-

Exists at this time as a pending join node.

-
- Selected node lives inside Exploration → Repro loop -
-
- - - -
-
- Pane 1 · Bottom transport: agent swimlane timeline - Apple-style scrubber: concurrency, event ordering, and replay cursor -
-
-
- 21:3321:3421:3521:3621:3721:3821:39 -
-
-
Mmanager
-
Aanalyst
-
Rrepro
-
Ppatcher
-
Eevaluator
-
-
-
-
plan + delegate
-
coordinate
- - -
-
-
inspect repo
- - -
-
-
reproduce issue
- - -
-
-
waiting
-
patch strategy
-
-
-
pending
- -
-
-
-
- Clicked event: worker_started - Repro worker begins “reproduce issue.” Pane 2 is replayed to sequence 42 and highlights the changed node. -
-
-
-
-
- - diff --git a/ergon-dashboard/mockups/mas-sequence-debugger.html b/ergon-dashboard/mockups/mas-sequence-debugger.html deleted file mode 100644 index a7a1e3e1..00000000 --- a/ergon-dashboard/mockups/mas-sequence-debugger.html +++ /dev/null @@ -1,803 +0,0 @@ - - - - - - MAS Sequence Debugger Mockup - - - -
-
-
- ergon/researchrubrics-vanilla/run-8427-ab3f/sample 42 -
-
-
-
-

MAS Sequence Debugger

- Executing - Record - Live - Task filters -
-
- Cursor: sequence 42 / 119 - Selected event: worker_started on Reproduce issue - Graph replayed from mutation WAL -
-
-
-
Nodes10
-
Mutations119
-
Events247
-
Score-
-
-
-
- -
-
-
- Main viewer · whole recursive graph at selected sequence - Timeline selects sequence T. Graph shows every node that exists at T. -
-
-
- Snapshot @ seq 42 - Whole graph at T - Selected task highlighted -
-
- Replay contract - Future nodes are absent. Existing pending/evaluation nodes remain visible. The highlight is selection, not filtering. -
- - - - - - - - - - -
-
Diamond root · graph snapshot10 nodes visible @ seq 42
-
-
-
Planning branch2 subtasks
-
-
-
Exploration branch3 subtasks
-
-
-
Repro looplevel 2
-
-
-
Implementation branch3 subtasks
-
-
-
Evaluation branch2 subtasks
-
- -
- - running -

Diamond root

-

Coordinates the graph and propagation.

-
-
done

Plan attack

-
done

Assign workers

- -
- - completed -

Inspect repo

-

Produced candidate files and context.

-
-
- - selected -

Reproduce issue

-

Started at the selected sequence.

-
-
- - ready -

Patch strategy

-

Ready, waiting on repro output.

-
- -
done

Run failing test

-
running

Capture traceback

- -
ready

Patch file

-
pending

Run tests

-
pending

Summarize

- -
pending

Score output

-
pending

Persist result

- -
- J - pending -

Join + score

-

Pending join node already exists at T.

-
-
-
- - - -
-
- Bottom dock · Run Record / Sequence Debugger - Stable semantic tracks. Actor/worker is metadata, not the lane axis. -
-
-
- seq 0153045607590119 -
-
-
Graph
-
Execution
-
Talk
-
Artifacts
-
Evaluation
-
-
-
- - - - -
-
- inspect repo - reproduce issue - patch waits - patch file -
-
- - - - -
-
- - - -
-
- - -
-
-
-
- Click event: jump graph to sequence - Click span: select related task - Filter by task/kind/actor - Actor shown on hover -
-
- Selected record: task.transition · seq 42 - Reproduce issue moved ready → running. The graph is replayed to this sequence; the drawer scopes details to the selected task. -
-
-
-
-
- - diff --git a/ergon-dashboard/src/components/run/RunWorkspacePage.tsx b/ergon-dashboard/src/components/run/RunWorkspacePage.tsx index 56c55a52..9d8b510d 100644 --- a/ergon-dashboard/src/components/run/RunWorkspacePage.tsx +++ b/ergon-dashboard/src/components/run/RunWorkspacePage.tsx @@ -60,7 +60,7 @@ export function RunWorkspacePage({ const [selectedActivityId, setSelectedActivityId] = useState(null); const [selectionNotice, setSelectionNotice] = useState(null); const [statusFilter, setStatusFilter] = useState(null); - const [isStreamOpen, setIsStreamOpen] = useState(true); + const [isStreamOpen, setIsStreamOpen] = useState(false); const { runState, isLoading, error, isSubscribed } = useRunState(runId, initialRunState); const { detail } = useCohortDetail(cohortId ?? "", initialCohortDetail); @@ -260,22 +260,27 @@ export function RunWorkspacePage({ }; return ( -
+
-
-
- - Experiment Cohorts +
+
+ + + Ergon +
+ + Cohorts + {cohortId && ( <> / {detail?.summary.name ?? "Cohort"} @@ -283,20 +288,33 @@ export function RunWorkspacePage({ )} / - {runId.slice(0, 8)}... + {runId.slice(0, 8)}... +
+ +
+

+ {runState?.name ?? runRow?.run_id ?? "Run"} +

+ +
-
-
-
-

- {runState?.name ?? runRow?.run_id ?? "Run"} -

- +
+
+ + Tasks {runState?.totalTasks ?? "—"} + + + Turns {runState?.completedTasks ?? 0} + + + Score {formatPercent(runState?.finalScore ?? runRow?.final_score ?? null)} + +
{(["live", "timeline"] as const).map((mode) => { const active = timelineMode === mode; @@ -311,10 +329,10 @@ export function RunWorkspacePage({ setTimelineMode(mode); if (mode === "live") setIsPlaying(false); }} - className={`rounded-md px-3 py-1 transition-colors ${ + className={`rounded px-2.5 py-1 transition-colors ${ active - ? "bg-indigo-600 text-white shadow-sm" - : "text-gray-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700" + ? "bg-white text-[#0c1118] shadow-sm" + : "text-[#64707f] hover:text-[#0c1118]" }`} data-testid={`mode-${mode}`} > @@ -327,53 +345,20 @@ export function RunWorkspacePage({ type="button" onClick={() => setIsStreamOpen((p) => !p)} aria-pressed={isStreamOpen} - className={`rounded-lg px-3 py-1 text-xs font-medium transition-colors ${ + className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${ isStreamOpen - ? "bg-slate-800 text-white dark:bg-slate-200 dark:text-slate-900" - : "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700" + ? "bg-[#0c1118] text-white" + : "border border-[#e2e6ec] bg-white text-[#64707f] hover:bg-[#eef0f3]" }`} title="Toggle event stream (e)" data-testid="event-stream-toggle" > - {isStreamOpen ? "Hide events" : "Show events"} + {isStreamOpen ? "Hide events" : "Event tracks"} -
-
- Workflow: {runState?.name ?? "—"} - Started: {runState?.startedAt ? new Date(runState.startedAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "medium" }) : "—"} -
-
- -
-
-
Runtime
-
- {formatSeconds(runState?.durationSeconds ?? (runRow?.running_time_ms != null ? runRow.running_time_ms / 1000 : null))} -
-
-
-
Score
-
- {formatPercent(runState?.finalScore ?? runRow?.final_score ?? null)} -
-
-
-
Tasks
-
- {runState?.totalTasks ?? "—"} -
-
-
-
Failed tasks
-
- {runState?.failedTasks ?? "—"} -
-
-
{leafTotal > 0 && ( -
+
{error} @@ -395,24 +380,18 @@ export function RunWorkspacePage({
{selectionNotice && (
{selectionNotice}
)}
0 && (
0 && (
+
) : (
-
+
Task inspection
-

- Graph first, then open a focused task workspace +

+ Click node {"->"} workspace drawer

-

- Select a task node to inspect its outputs, execution attempts, actions, - communication, and evaluation without keeping the entire page in a cramped - permanent split view. -

+

State, outputs, turns, and evals appear scoped to the selected sequence.

{selectedTask && ( -
- Ready to inspect {selectedTask.name}. +
+ Ready to inspect {selectedTask.name}.
)}
diff --git a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx index 2ab3e00b..23489a05 100644 --- a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx +++ b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx @@ -13,7 +13,7 @@ import { formatTaskWallTimestamp } from "@/features/graph/utils/taskTiming"; import { filterTaskEvidenceForTime } from "./filterTaskEvidenceForTime"; function EmptySection({ message }: { message: string }) { - return
{message}
; + return
{message}
; } function WorkspaceSection({ @@ -27,10 +27,10 @@ function WorkspaceSection({ }) { return (
-

+

{title}

{children} @@ -72,7 +72,7 @@ export function TaskWorkspace({ if (!taskId) { return (
Select a task from the graph to open the focused task workspace. @@ -83,7 +83,7 @@ export function TaskWorkspace({ if (isLoading) { return (
Loading task workspace... @@ -94,7 +94,7 @@ export function TaskWorkspace({ if (!task || error) { return (
{error ?? "Task not found"} @@ -117,17 +117,22 @@ export function TaskWorkspace({ const ended = formatTaskWallTimestamp(task.completedAt); return ( -
+
-
-

{task.name}

+
+
+
+ Task workspace +
+

{task.name}

+
{selectedSequence !== null && ( Viewing seq {selectedSequence} @@ -137,14 +142,14 @@ export function TaskWorkspace({ )}
-
+
Worker: {task.assignedWorkerName ?? "—"} Level: {task.level} Leaf task: {task.isLeaf ? "yes" : "no"} @@ -156,7 +161,7 @@ export function TaskWorkspace({ @@ -170,7 +175,7 @@ export function TaskWorkspace({ @@ -180,11 +185,11 @@ export function TaskWorkspace({
{task.description && ( -

{task.description}

+

{task.description}

)}
-
+
@@ -246,7 +251,7 @@ export function TaskWorkspace({ )}
-
+
diff --git a/ergon-dashboard/src/features/activity/components/ActivityBar.tsx b/ergon-dashboard/src/features/activity/components/ActivityBar.tsx index af4ad1af..6a786ae6 100644 --- a/ergon-dashboard/src/features/activity/components/ActivityBar.tsx +++ b/ergon-dashboard/src/features/activity/components/ActivityBar.tsx @@ -4,49 +4,63 @@ import type { ActivityStackItem, ActivityKind, RunActivity } from "@/features/ac const KIND_STYLES: Record< ActivityKind, - { bar: string; marker: string; text: string; label: string } + { bar: string; marker: string; text: string; label: string; fill: string; stroke: string } > = { execution: { - bar: "bg-indigo-600 border-indigo-700", - marker: "bg-indigo-600", - text: "text-indigo-900 dark:text-indigo-100", + bar: "bg-amber-400 border-amber-300 text-[#2a1800]", + marker: "bg-amber-400 text-amber-400", + text: "text-[#2a1800]", label: "Execution", + fill: "#fbbf24", + stroke: "#fde68a", }, graph: { - bar: "bg-sky-600 border-sky-700", - marker: "bg-sky-600", - text: "text-sky-900 dark:text-sky-100", + bar: "bg-violet-400 border-violet-300 text-[#160b2f]", + marker: "bg-violet-400 text-violet-400", + text: "text-[#160b2f]", label: "Graph", + fill: "#a78bfa", + stroke: "#ddd6fe", }, message: { - bar: "bg-amber-500 border-amber-600", - marker: "bg-amber-500", - text: "text-amber-900 dark:text-amber-100", + bar: "bg-cyan-400 border-cyan-300 text-[#06242a]", + marker: "bg-cyan-400 text-cyan-400", + text: "text-[#06242a]", label: "Talk", + fill: "#22d3ee", + stroke: "#a5f3fc", }, artifact: { - bar: "bg-lime-600 border-lime-700", - marker: "bg-lime-600", - text: "text-lime-900 dark:text-lime-100", + bar: "bg-emerald-400 border-emerald-300 text-[#052e1d]", + marker: "bg-emerald-400 text-emerald-400", + text: "text-[#052e1d]", label: "Artifact", + fill: "#34d399", + stroke: "#a7f3d0", }, evaluation: { - bar: "bg-fuchsia-600 border-fuchsia-700", - marker: "bg-fuchsia-600", - text: "text-fuchsia-900 dark:text-fuchsia-100", + bar: "bg-rose-400 border-rose-300 text-[#3a0610]", + marker: "bg-rose-400 text-rose-400", + text: "text-[#3a0610]", label: "Evaluation", + fill: "#fb7185", + stroke: "#fecdd3", }, context: { - bar: "bg-cyan-600 border-cyan-700", - marker: "bg-cyan-600", - text: "text-cyan-900 dark:text-cyan-100", + bar: "bg-cyan-300 border-cyan-200 text-[#06242a]", + marker: "bg-cyan-300 text-cyan-300", + text: "text-[#06242a]", label: "Context", + fill: "#67e8f9", + stroke: "#cffafe", }, sandbox: { - bar: "bg-slate-600 border-slate-700", - marker: "bg-slate-600", - text: "text-slate-900 dark:text-slate-100", + bar: "bg-slate-500 border-slate-400 text-white", + marker: "bg-slate-500 text-slate-500", + text: "text-white", label: "Sandbox", + fill: "#94a3b8", + stroke: "#cbd5e1", }, }; @@ -76,15 +90,18 @@ export function ActivityBar({ + ))} +
+
+ + {points.length === 0 ? ( +
+ No {selectedLabel.toLowerCase()} values are available yet. +
+ ) : ( +
+
+
+ {points.map((point) => { + const valueLabel = formatMetricValue(selectedMetric, point.value); + return ( + + + {point.run.run_id} {selectedLabel} {valueLabel} + + + ); + })} +
+
+ {formatMetricValue(selectedMetric, min)} + {points.length} run{points.length === 1 ? "" : "s"} + {formatMetricValue(selectedMetric, max)} +
+
+ )} +
+ ); +} + +/* ────────────────────────────────────────────────────────── */ +/* Run Row */ +/* ────────────────────────────────────────────────────────── */ + function CohortRunRowCard({ cohortId, run }: { cohortId: string; run: CohortRunRow }) { const started = formatStartedAt(run.started_at); return (
- + {run.run_id}
-
+
{run.cohort_name} {run.run_id.slice(0, 8)}...
{run.error_message && ( -
{run.error_message}
+
+ {run.error_message} +
)}
-
Benchmark
-
{run.cohort_name}
+
Benchmark
+
{run.cohort_name}
-
Status
-
{run.status}
+
Status
+
{run.status}
-
Started
-
+
Started
+
{started.dateTime ? (
-
Runtime
-
+
Runtime
+
{formatDurationMs(run.running_time_ms)}
-
Score
-
+
Score
+
{formatScore(run.final_score)}
@@ -88,6 +356,36 @@ function CohortRunRowCard({ cohortId, run }: { cohortId: string; run: CohortRunR ); } +/* ────────────────────────────────────────────────────────── */ +/* Empty State */ +/* ────────────────────────────────────────────────────────── */ + +function EmptyRunsState() { + return ( +
+ +

No runs yet

+

+ This cohort has no runs. Launch a benchmark run targeting this cohort to get started. +

+ +
+ ); +} + +/* ────────────────────────────────────────────────────────── */ +/* Main View */ +/* ────────────────────────────────────────────────────────── */ + export function CohortDetailView({ cohortId, initialDetail = null, @@ -99,7 +397,7 @@ export function CohortDetailView({ if (isLoading) { return ( -
+
Loading cohort...
); @@ -107,108 +405,92 @@ export function CohortDetailView({ if (!detail) { return ( -
+
{error ?? "Cohort not found"}
); } const { summary, runs } = detail; + const stats = buildDetailStats(summary, runs); return ( -
-
+
+
- Experiment Cohorts + Cohorts
-

+

{summary.name}

-

+

{summary.description ?? "Monitor cohort progress, inspect runs, and drill into task-level evidence."}

-
-
Model
-
- {summary.metadata_summary.model_name ?? "—"} -
-
{error && ( -
+
{error}
)} -
-
-
Total runs
-
- {summary.total_runs} -
-
-
-
Executing
-
- {summary.status_counts.executing} -
-
-
-
Completed
-
- {summary.status_counts.completed} -
-
-
-
Failed
-
- {summary.status_counts.failed} -
-
-
-
Average score
-
- {formatScore(summary.average_score)} -
-
-
-
Failure rate
-
- {formatScore(summary.failure_rate)} -
-
+ {/* 5-tile summary row */} +
+ + + + +
+ + + {/* Runs section */}
-

Runs

-

+

Runs

+

Select a run to inspect graph topology and task workspace evidence.

-
- {runs.map((run) => ( - - ))} -
+ {runs.length === 0 ? ( + + ) : ( +
+ {runs.map((run) => ( + + ))} +
+ )}
diff --git a/ergon-dashboard/src/components/cohorts/CohortListView.tsx b/ergon-dashboard/src/components/cohorts/CohortListView.tsx index 62905fb8..0dd02222 100644 --- a/ergon-dashboard/src/components/cohorts/CohortListView.tsx +++ b/ergon-dashboard/src/components/cohorts/CohortListView.tsx @@ -5,7 +5,6 @@ import { useMemo, useState } from "react"; import { useCohorts } from "@/hooks/useCohorts"; import { StatusBadge } from "@/components/common/StatusBadge"; -import { SearchInput } from "@/components/common/SearchInput"; import { getCohortDisplayStatus } from "@/lib/cohortStatus"; import { CohortSummary } from "@/lib/types"; @@ -101,122 +100,49 @@ function sortCohorts(cohorts: CohortSummary[], sortKey: SortKey): CohortSummary[ return sorted; } -function QuickFilterButton({ - label, - count, - active, - onClick, -}: { - label: string; - count: number; - active: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -function ArchiveActionButton({ - cohort, - isUpdating, - onToggle, -}: { - cohort: CohortSummary; - isUpdating: boolean; - onToggle: (cohort: CohortSummary) => Promise; -}) { - const isArchived = cohort.status === "archived"; - - return ( - - ); -} - -function SummaryCard({ - label, +function SegmentedControl({ + options, value, - helper, + onChange, }: { - label: string; - value: string | number; - helper: string; + options: { key: T; label: string; count?: number }[]; + value: T; + onChange: (key: T) => void; }) { return ( -
-
{label}
-
{value}
-
{helper}
+
+ {options.map((opt) => ( + + ))}
); } -function ProgressBar({ cohort }: { cohort: CohortSummary }) { - const total = Math.max(cohort.total_runs, 1); - const segments = [ - { - key: "completed", - value: cohort.status_counts.completed, - className: "bg-emerald-500", - }, - { - key: "failed", - value: cohort.status_counts.failed, - className: "bg-red-500", - }, - { - key: "executing", - value: cohort.status_counts.executing + cohort.status_counts.evaluating, - className: "bg-blue-500", - }, - { - key: "pending", - value: cohort.status_counts.pending, - className: "bg-gray-300 dark:bg-gray-700", - }, - ].filter((segment) => segment.value > 0); +function failureColor(rate: number): string { + if (rate > 0.30) return "oklch(0.50 0.16 22)"; + if (rate > 0.15) return "oklch(0.50 0.10 80)"; + return "var(--muted)"; +} - return ( -
-
- Run progress - - {cohort.status_counts.completed + cohort.status_counts.failed}/{cohort.total_runs} finished - -
-
- {segments.map((segment) => ( -
- ))} -
-
- ); +function formatTimeHHMMSS(): string { + const now = new Date(); + return [now.getHours(), now.getMinutes(), now.getSeconds()] + .map((n) => String(n).padStart(2, "0")) + .join(":"); } export function CohortListView() { @@ -243,10 +169,6 @@ export function CohortListView() { [cohorts], ); - const totalRuns = useMemo( - () => cohorts.reduce((sum, cohort) => sum + cohort.total_runs, 0), - [cohorts], - ); const activeCohorts = useMemo( () => visibleCohortList.filter((cohort) => cohort.status === "active").length, [visibleCohortList], @@ -279,387 +201,203 @@ export function CohortListView() { } }; + // Suppress unused-var lint — archive toggle is still wired but hidden in the new grid rows + void updatingCohortIds; + void handleArchiveToggle; + if (isLoading) { return ( -
+
Loading cohorts...
); } return ( -
+
-
-
-

- Ergon Dashboard -

-

- Experiment Cohorts -

-

- Monitor cohorts first, then drill into runs and task workspaces from the same - operator surface. -

-
-
-
Visible cohorts
-
- {visibleCohorts} -
-
+
+ + Workspace + +

+ Cohorts +

+

+ Monitor cohorts first, then drill into runs and task workspaces from the same + operator surface. +

-
+
{error && (
{error}
)} - {cohorts.length > 0 && ( -
- - - - -
- )} - {cohorts.length === 0 ? (
-

+

No cohorts yet

-

+

Start a benchmark run with a compulsory cohort name to create the first cohort.

) : (
-
-
-
-
- Find the right cohort faster -
-
- Search by cohort, model, benchmark, prompt version, creator, or description. -
-
-
- Showing {filteredCohorts.length} of{" "} - {statusFilter === "archived" ? archivedCohorts : visibleCohorts} cohorts -
-
- -
- setStatusFilter("all")} - /> - setStatusFilter("needs-attention")} - /> - setStatusFilter("running")} - /> - setStatusFilter("active")} - /> - setStatusFilter("archived")} - /> -
- -
- +
+ setQuery(e.target.value)} + placeholder="Filter cohorts…" + className="w-[220px] rounded-[var(--radius-sm)] border border-[var(--line)] bg-[var(--paper)] px-3 py-1.5 text-xs text-[var(--ink)] placeholder:text-[var(--faint)] focus:border-[var(--accent)] focus:outline-none" + data-testid="cohort-search-input" /> - -
-
+ + value={statusFilter} + onChange={setStatusFilter} + options={[ + { key: "all", label: "All", count: visibleCohorts }, + { key: "active", label: "Active", count: activeCohorts }, + { key: "running", label: "Running", count: runningCohorts }, + { key: "needs-attention", label: "Needs attention", count: cohortsNeedingAttention }, + { key: "archived", label: "Archived", count: archivedCohorts }, + ]} + /> + + value={sortKey} + onChange={setSortKey} + options={[ + { key: "recent", label: "Recent" }, + { key: "score", label: "Score" }, + { key: "failure", label: "Failure rate" }, + { key: "runs", label: "Runs" }, + ]} + /> +
{filteredCohorts.length === 0 ? ( -
-

+
+

No cohorts match these filters

-

+

Try clearing the search, changing the status filter, or sorting by a different signal.

) : ( - <> -
-
Cohort
-
Runs
-
Running
-
Completed
-
Failure rate
-
Avg score
-
Latest activity
-
Actions
+
+ {/* Table header */} +
+ {["Cohort", "Runs", "Avg score", "Failure", "Runtime", "Status", ""].map( + (col) => ( +
+ {col} +
+ ), + )}
+ {/* Table rows */} {filteredCohorts.map((cohort) => ( -
-
-
-
- - {cohort.name} - - - {cohort.status_counts.failed > 0 && ( - - Needs attention - - )} -
- - {cohort.description && ( -

- {cohort.description} -

- )} - -
- - Model: {cohort.metadata_summary.model_name ?? "—"} - - - By: {cohort.created_by ?? "Unknown"} - - - Avg runtime: {formatDurationMs(cohort.average_duration_ms)} - -
- -
- -
-
- -
- {cohort.total_runs} -
-
- {cohort.status_counts.executing + cohort.status_counts.evaluating} -
-
- {cohort.status_counts.completed} -
-
- {formatPercent(cohort.failure_rate)} + {/* Cohort name + sub ID */} +
+
+ {cohort.name}
-
- {formatPercent(cohort.average_score)} -
-
-
- {formatRelativeTime(getLatestActivityAt(cohort))} -
-
{new Date(cohort.created_at).toLocaleDateString()}
-
-
- - Open - - +
+ {cohort.cohort_id.slice(0, 12)}
-
-
-
-

- {cohort.name} -

- - {cohort.status_counts.failed > 0 && ( - - Needs attention - - )} -
- - {cohort.description && ( -

- {cohort.description} -

- )} - -
- - Model: {cohort.metadata_summary.model_name ?? "—"} - - - Created by: {cohort.created_by ?? "Unknown"} - - - Latest activity: {formatRelativeTime(getLatestActivityAt(cohort))} - - - Created: {new Date(cohort.created_at).toLocaleDateString()} - -
- -
- -
-
+ {/* Runs */} +
+ {cohort.total_runs} +
-
-
-
Runs
-
- {cohort.total_runs} -
-
-
-
Completed
-
- {cohort.status_counts.completed} -
-
-
-
Running
-
- {cohort.status_counts.executing + cohort.status_counts.evaluating} -
-
-
-
Failure rate
-
- {formatPercent(cohort.failure_rate)} -
-
-
-
Avg score
-
- {formatPercent(cohort.average_score)} -
-
-
-
Avg runtime
-
- {formatDurationMs(cohort.average_duration_ms)} -
-
-
- -
- - Open cohort - - -
+ {/* Avg score */} +
+ {formatPercent(cohort.average_score)} +
+ + {/* Failure rate */} +
+ {formatPercent(cohort.failure_rate)} +
+ + {/* Runtime · last activity */} +
+ {formatDurationMs(cohort.average_duration_ms)} + + · {formatRelativeTime(getLatestActivityAt(cohort))} +
-
+ + {/* Status */} +
+ +
+ + {/* Chevron */} +
+ ))} - + + {/* Footer */} +
+ + Showing {filteredCohorts.length} of{" "} + {statusFilter === "archived" ? archivedCohorts : visibleCohorts} cohorts + + + Updated {formatTimeHHMMSS()} · live + + +
+
)}
)} diff --git a/ergon-dashboard/src/components/common/BuildHealthToast.tsx b/ergon-dashboard/src/components/common/BuildHealthToast.tsx new file mode 100644 index 00000000..76fed334 --- /dev/null +++ b/ergon-dashboard/src/components/common/BuildHealthToast.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { useBuildHealth } from "@/hooks/useBuildHealth"; + +export function BuildHealthToast() { + const { status, errors, check } = useBuildHealth(); + const [dismissed, setDismissed] = useState(false); + + if (status !== "degraded" || dismissed) return null; + + const hasSSRFailure = errors.some( + (e) => e.includes("SSR import") || e.includes("Cannot find module"), + ); + const hasApiFailure = errors.some((e) => e.includes("Ergon API")); + + let headline: string; + let advice: string; + + if (hasSSRFailure) { + headline = "Stale build detected"; + advice = + "The Next.js dev server has a corrupted cache. " + + "Run: rm -rf .next && docker compose restart dashboard"; + } else if (hasApiFailure) { + headline = "Backend API unreachable"; + advice = + "The Ergon API is not responding. Check that the API container is running: " + + "docker compose ps api"; + } else { + headline = "Dashboard health degraded"; + advice = errors[0] ?? "Unknown issue — check server logs."; + } + + return ( +
+ + + + +
+

+ {headline} +

+

{advice}

+ {errors.length > 1 && ( +
+ + {errors.length} details + +
    + {errors.map((e, i) => ( +
  • {e}
  • + ))} +
+
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/ergon-dashboard/src/components/common/ClientLayout.tsx b/ergon-dashboard/src/components/common/ClientLayout.tsx index 93938c5c..4eb77423 100644 --- a/ergon-dashboard/src/components/common/ClientLayout.tsx +++ b/ergon-dashboard/src/components/common/ClientLayout.tsx @@ -1,13 +1,8 @@ "use client"; -/** - * ClientLayout - Client-side layout wrapper. - * - * Includes components that need client-side functionality, - * like the ConnectionStatus banner. - */ - +import { BuildHealthToast } from "./BuildHealthToast"; import { ConnectionStatus } from "./ConnectionStatus"; +import { Topbar } from "@/components/shell/Topbar"; interface ClientLayoutProps { children: React.ReactNode; @@ -15,9 +10,11 @@ interface ClientLayoutProps { export function ClientLayout({ children }: ClientLayoutProps) { return ( - <> - {children} +
+ +
{children}
- + +
); } diff --git a/ergon-dashboard/src/components/common/StatusBadge.tsx b/ergon-dashboard/src/components/common/StatusBadge.tsx index 38004bdf..6ab5abc9 100644 --- a/ergon-dashboard/src/components/common/StatusBadge.tsx +++ b/ergon-dashboard/src/components/common/StatusBadge.tsx @@ -1,175 +1,149 @@ "use client"; -/** - * StatusBadge - Color-coded status indicator for tasks and runs. - * - * Displays task status with appropriate colors: - * - pending: gray - * - ready: blue - * - running: yellow (with pulse animation) - * - completed: green - * - failed: red - */ - import { ExperimentCohortStatus, RunLifecycleStatus, TaskStatus } from "@/lib/types"; -// Status type includes TaskStatus enum values and run-level status strings -// Note: TaskStatus.RUNNING = "running", TaskStatus.COMPLETED = "completed", etc. -// So "running" | "completed" | "failed" are already covered by TaskStatus type StatusType = TaskStatus | RunLifecycleStatus | ExperimentCohortStatus; -interface StatusBadgeProps { - status: StatusType; - size?: "sm" | "md"; - showLabel?: boolean; -} - interface StatusConfig { - bg: string; - text: string; - ring: string; label: string; + dot: string; + solidBg: string; + solidBorder: string; + solidText: string; animate?: boolean; - color: string; } const statusConfig: Record = { [TaskStatus.PENDING]: { - bg: "bg-gray-100 dark:bg-gray-800", - text: "text-gray-600 dark:text-gray-400", - ring: "ring-gray-200 dark:ring-gray-700", label: "Pending", - color: "#9ca3af", + dot: "var(--status-pending)", + solidBg: "var(--paper-2)", + solidBorder: "var(--line)", + solidText: "var(--muted)", }, [TaskStatus.READY]: { - bg: "bg-blue-100 dark:bg-blue-900/30", - text: "text-blue-600 dark:text-blue-400", - ring: "ring-blue-200 dark:ring-blue-800", label: "Ready", - color: "#3b82f6", + dot: "var(--status-ready)", + solidBg: "oklch(0.97 0.03 240)", + solidBorder: "oklch(0.86 0.08 240)", + solidText: "oklch(0.40 0.12 240)", }, [TaskStatus.RUNNING]: { - bg: "bg-yellow-100 dark:bg-yellow-900/30", - text: "text-yellow-700 dark:text-yellow-400", - ring: "ring-yellow-200 dark:ring-yellow-800", label: "Running", + dot: "var(--status-running)", + solidBg: "oklch(0.96 0.04 80)", + solidBorder: "oklch(0.85 0.10 80)", + solidText: "oklch(0.42 0.12 65)", animate: true, - color: "#eab308", }, [TaskStatus.COMPLETED]: { - bg: "bg-green-100 dark:bg-green-900/30", - text: "text-green-600 dark:text-green-400", - ring: "ring-green-200 dark:ring-green-800", label: "Completed", - color: "#22c55e", + dot: "var(--status-completed)", + solidBg: "oklch(0.96 0.04 155)", + solidBorder: "oklch(0.85 0.10 155)", + solidText: "oklch(0.40 0.12 155)", }, [TaskStatus.FAILED]: { - bg: "bg-red-100 dark:bg-red-900/30", - text: "text-red-600 dark:text-red-400", - ring: "ring-red-200 dark:ring-red-800", label: "Failed", - color: "#ef4444", + dot: "var(--status-failed)", + solidBg: "oklch(0.96 0.04 22)", + solidBorder: "oklch(0.85 0.10 22)", + solidText: "oklch(0.40 0.16 22)", }, [TaskStatus.CANCELLED]: { - bg: "bg-gray-100 dark:bg-gray-800", - text: "text-gray-500 dark:text-gray-400", - ring: "ring-gray-200 dark:ring-gray-700", label: "Cancelled", - color: "#9ca3af", + dot: "var(--status-cancelled)", + solidBg: "var(--paper-2)", + solidBorder: "var(--line)", + solidText: "var(--muted)", }, executing: { - bg: "bg-yellow-100 dark:bg-yellow-900/30", - text: "text-yellow-700 dark:text-yellow-400", - ring: "ring-yellow-200 dark:ring-yellow-800", label: "Executing", + dot: "var(--status-running)", + solidBg: "oklch(0.96 0.04 80)", + solidBorder: "oklch(0.85 0.10 80)", + solidText: "oklch(0.42 0.12 65)", animate: true, - color: "#eab308", }, evaluating: { - bg: "bg-violet-100 dark:bg-violet-900/30", - text: "text-violet-700 dark:text-violet-400", - ring: "ring-violet-200 dark:ring-violet-800", label: "Evaluating", + dot: "oklch(0.74 0.16 295)", + solidBg: "oklch(0.96 0.04 295)", + solidBorder: "oklch(0.85 0.10 295)", + solidText: "oklch(0.40 0.16 295)", animate: true, - color: "#8b5cf6", }, active: { - bg: "bg-blue-100 dark:bg-blue-900/30", - text: "text-blue-700 dark:text-blue-400", - ring: "ring-blue-200 dark:ring-blue-800", label: "Active", - color: "#3b82f6", + dot: "var(--status-ready)", + solidBg: "oklch(0.97 0.03 240)", + solidBorder: "oklch(0.86 0.08 240)", + solidText: "oklch(0.40 0.12 240)", }, archived: { - bg: "bg-gray-100 dark:bg-gray-800", - text: "text-gray-600 dark:text-gray-400", - ring: "ring-gray-200 dark:ring-gray-700", label: "Archived", - color: "#9ca3af", + dot: "var(--status-cancelled)", + solidBg: "var(--paper-2)", + solidBorder: "var(--line)", + solidText: "var(--muted)", }, }; -// Default config for unknown statuses const defaultConfig: StatusConfig = { - bg: "bg-gray-100 dark:bg-gray-800", - text: "text-gray-600 dark:text-gray-400", - ring: "ring-gray-200 dark:ring-gray-700", label: "Unknown", - color: "#9ca3af", + dot: "var(--faint)", + solidBg: "var(--paper-2)", + solidBorder: "var(--line)", + solidText: "var(--muted)", }; +interface StatusBadgeProps { + status: StatusType; + variant?: "outline" | "solid"; + size?: "sm" | "md"; + showLabel?: boolean; +} + export function StatusBadge({ status, + variant = "solid", size = "md", showLabel = true, }: StatusBadgeProps) { + const sizeClass = size === "sm" ? "text-[10px] px-1.5 py-px" : "text-[11px] px-2 py-0.5"; const config = statusConfig[status] || defaultConfig; - const sizeClasses = { - sm: { - badge: "px-1.5 py-0.5 text-xs", - dot: "w-1.5 h-1.5", - }, - md: { - badge: "px-2 py-1 text-sm", - dot: "w-2 h-2", - }, - }; - - const sizes = sizeClasses[size]; - - return ( - - {/* Status dot */} - + if (variant === "outline") { + return ( + - {config.animate && ( - - )} + {showLabel && {config.label}} + ); + } - {/* Label */} + return ( + + {showLabel && {config.label}} ); } -/** - * Compact dot-only status indicator for use in tight spaces. - */ export function StatusDot({ status, size = "md", @@ -178,23 +152,18 @@ export function StatusDot({ size?: "sm" | "md" | "lg"; }) { const config = statusConfig[status] || defaultConfig; - - const sizeClasses = { - sm: "w-2 h-2", - md: "w-3 h-3", - lg: "w-4 h-4", - }; + const sizeClasses = { sm: "size-2", md: "size-3", lg: "size-4" }; return ( {config.animate && ( )} diff --git a/ergon-dashboard/src/components/common/TransitionChip.tsx b/ergon-dashboard/src/components/common/TransitionChip.tsx index c37a81dc..b519efc5 100644 --- a/ergon-dashboard/src/components/common/TransitionChip.tsx +++ b/ergon-dashboard/src/components/common/TransitionChip.tsx @@ -13,6 +13,7 @@ import { TaskStatus, TaskTrigger } from "@/lib/types"; import { tokensFor } from "@/lib/statusTokens"; +import { formatClockTimeMs } from "@/lib/timeFormat"; const TRIGGER_LABELS: Record = { [TaskTrigger.WORKFLOW_STARTED]: "workflow started", @@ -26,16 +27,8 @@ const TRIGGER_LABELS: Record = { function formatTimeMs(iso: string | null): string { if (!iso) return "—"; - try { - return new Date(iso).toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - fractionalSecondDigits: 3, - }); - } catch { - return iso; - } + const label = formatClockTimeMs(iso); + return label === "—" ? iso : label; } interface TransitionChipProps { diff --git a/ergon-dashboard/src/components/dag/DAGCanvas.tsx b/ergon-dashboard/src/components/dag/DAGCanvas.tsx index 9571e09c..ef653852 100644 --- a/ergon-dashboard/src/components/dag/DAGCanvas.tsx +++ b/ergon-dashboard/src/components/dag/DAGCanvas.tsx @@ -5,23 +5,22 @@ * * Features: * - Hierarchical dagre layout with nested container rendering - * - Depth-based expansion control via DepthSelector + * - Depth-based expansion control via floating controls * - Search/filter tasks by name * - Live updates via useRunState hook * - Zoom/pan controls */ -import { useCallback, useEffect, useState, useMemo } from "react"; +import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { ReactFlow, Edge, Background, - Controls, MiniMap, useNodesState, useEdgesState, + useReactFlow, ConnectionLineType, - Panel, BackgroundVariant, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; @@ -29,7 +28,6 @@ import "@xyflow/react/dist/style.css"; import { TaskStatus, type WorkflowRunState } from "@/lib/types"; import { nodeTypes, type TaskNodeType } from "./TaskNode"; import { GraphDependencyEdge } from "./edges/GraphDependencyEdge"; -import { DepthSelector } from "@/features/graph/components/DepthSelector"; import { GraphExpansionProvider } from "@/features/graph/hooks/useGraphExpansion"; import { computeHierarchicalLayout, calculateExpandedContainers } from "@/features/graph/layout/hierarchicalLayout"; import { DEFAULT_EXPANDED_DEPTH } from "@/features/graph/layout/layoutTypes"; @@ -72,6 +70,139 @@ function getMinimapNodeColor(node: TaskNodeType): string { } } +/* ─── Floating control cards ────────────────────────────────────── */ + +const cardClass = + "bg-[var(--card)] border border-[var(--line)] rounded-lg shadow-card"; + +function ZoomControls() { + const { zoomIn, zoomOut, fitView } = useReactFlow(); + const btn = + "flex items-center justify-center w-7 h-7 text-sm font-semibold text-[var(--muted)] hover:text-[var(--ink)] hover:bg-[var(--paper)] rounded transition-colors"; + return ( +
+ + + + + +
+ ); +} + +function DepthSelectorCard({ + maxAvailableDepth, + currentDepth, + onDepthChange, +}: { + maxAvailableDepth: number; + currentDepth: number | "all"; + onDepthChange: (depth: number | "all") => void; +}) { + const depths: (number | "all")[] = []; + for (let i = 1; i <= Math.min(maxAvailableDepth, 3); i++) depths.push(i); + depths.push("all"); + + return ( +
+ + Depth + +
+ {depths.map((d) => { + const isActive = currentDepth === d; + return ( + + ); + })} +
+
+ ); +} + +function SearchCard({ + searchQuery, + onSearchChange, + matchCount, +}: { + searchQuery: string; + onSearchChange: (value: string) => void; + matchCount: number; +}) { + return ( +
+ + Search + + + {searchQuery && ( + + {matchCount} + + )} +
+ ); +} + +const LEGEND_ITEMS: { status: string; label: string; cssVar: string }[] = [ + { status: "completed", label: "completed", cssVar: "var(--status-completed)" }, + { status: "running", label: "running", cssVar: "var(--status-running)" }, + { status: "ready", label: "ready", cssVar: "var(--status-ready)" }, + { status: "pending", label: "pending", cssVar: "var(--status-pending)" }, + { status: "failed", label: "failed", cssVar: "var(--status-failed)" }, +]; + +function LegendCard() { + return ( +
+ {LEGEND_ITEMS.map((item) => ( +
+ + + {item.label} + +
+ ))} +
+ ); +} + +/* ─── Main canvas ───────────────────────────────────────────────── */ + function DAGCanvasInner({ runId, runState, @@ -89,6 +220,8 @@ function DAGCanvasInner({ const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [containerDims, setContainerDims] = useState>(new Map()); const [prevTaskIds, setPrevTaskIds] = useState>(new Set()); + const { fitView: rfFitView } = useReactFlow(); + const fitViewTimer = useRef | null>(null); const newNodeIds = useMemo(() => { if (!runState?.tasks) return new Set(); @@ -106,7 +239,6 @@ function DAGCanvasInner({ } }, [runState?.tasks]); - // Compute max available depth from tasks const maxAvailableDepth = useMemo(() => { if (!runState?.tasks) return 0; let max = 0; @@ -116,12 +248,10 @@ function DAGCanvasInner({ return max; }, [runState?.tasks]); - // Compute expanded containers from depth + manual overrides const expandedContainers = useMemo(() => { if (!runState?.tasks) return new Set(); const maxDepth = expandedDepth === "all" ? Infinity : expandedDepth; const fromDepth = calculateExpandedContainers(runState.tasks, maxDepth); - // Merge manual expansions (toggled individually) for (const id of manualExpansions) { if (fromDepth.has(id)) { fromDepth.delete(id); @@ -135,7 +265,6 @@ function DAGCanvasInner({ return fromDepth; }, [runState?.tasks, expandedDepth, manualExpansions]); - // Calculate matching node count const matchCount = useMemo(() => { if (!searchQuery.trim() || !runState?.tasks) return 0; const searchLower = searchQuery.toLowerCase().trim(); @@ -152,7 +281,6 @@ function DAGCanvasInner({ return count; }, [searchQuery, runState?.tasks]); - // Compute hierarchical layout when data changes useEffect(() => { if (!runState?.tasks || runState.tasks.size === 0) return; @@ -170,6 +298,11 @@ function DAGCanvasInner({ setNodes(result.nodes as TaskNodeType[]); setEdges(result.edges); setContainerDims(result.containerDimensions); + + if (fitViewTimer.current) clearTimeout(fitViewTimer.current); + fitViewTimer.current = setTimeout(() => { + rfFitView({ padding: 0.2, duration: 200 }); + }, 100); }, [ runState?.tasks, expandedContainers, @@ -180,15 +313,14 @@ function DAGCanvasInner({ highlightedTaskIds, setNodes, setEdges, + rfFitView, ]); - // Handle depth change — reset manual overrides when depth changes const handleDepthChange = useCallback((depth: number | "all") => { setExpandedDepth(depth); setManualExpansions(new Set()); }, []); - // Toggle individual container expansion const toggleExpand = useCallback((taskId: string) => { setManualExpansions((prev) => { const next = new Set(prev); @@ -201,7 +333,6 @@ function DAGCanvasInner({ }); }, []); - // Handle search change const handleSearchChange = useCallback((value: string) => { setSearchQuery(value); }, []); @@ -211,25 +342,17 @@ function DAGCanvasInner({ [expandedContainers, toggleExpand, containerDims], ); - // Loading state if (isLoading) { return ( -
-
+
+
- + +
-
- +
+ {isNotFoundError ? (
-

+

{isNotFoundError ? "Run Data Unavailable" : "Connection Error"}

-

{error}

-

+

{error}

+

Run ID: {runId}

@@ -284,18 +401,12 @@ function DAGCanvasInner({ ); } - // Empty state if (!runState?.tasks || runState.tasks.size === 0) { return ( -
+
-
- +
+
-

+

Waiting for tasks...

-

+

{isSubscribed ? "Subscribed to run updates. Tasks will appear when the workflow starts." : "Connecting to server..."}

-

+

Run ID: {runId}

@@ -321,7 +432,7 @@ function DAGCanvasInner({ } return ( -
+
- {/* Background */} - {/* Controls */} - - - {/* MiniMap */} - - {/* Top Left: Depth Selector + Search */} - -
- -
-
- - {searchQuery && ( - - {matchCount} match{matchCount !== 1 ? "es" : ""} - - )} -
- {/* Depth selector on mobile */} -
- -
-
- - {/* Run Info Panel */} - -
-

- {runState.name} -

-
- - - {runState.status} - - {runState.durationSeconds !== null && ( - {Math.round(runState.durationSeconds)}s - )} -
-
-
+ + {/* Floating controls — top-left */} +
+ + + +
+ + {/* Floating controls — bottom-left */} +
+ +
); diff --git a/ergon-dashboard/src/components/panels/CommunicationPanel.tsx b/ergon-dashboard/src/components/panels/CommunicationPanel.tsx index 58f654f7..3617da3c 100644 --- a/ergon-dashboard/src/components/panels/CommunicationPanel.tsx +++ b/ergon-dashboard/src/components/panels/CommunicationPanel.tsx @@ -1,20 +1,43 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; + import { CommunicationThreadState } from "@/lib/types"; +import { formatClockTimeSeconds } from "@/lib/timeFormat"; function formatTime(timestamp: string): string { - const date = new Date(timestamp); - return date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + return formatClockTimeSeconds(timestamp); } function speakerLabel(agentId: string): string { const suffix = agentId.split(":").pop() ?? agentId; - return suffix.replaceAll("_", " "); + return suffix.replaceAll("_", " ").replaceAll("-", " "); +} + +function threadSummary(thread: CommunicationThreadState): string { + if (thread.summary) return thread.summary; + const participants = participantLabels(thread); + if (thread.messages.length === 0) { + return participants.length > 0 + ? `Thread between ${participants.join(", ")}.` + : "No messages are visible at this point in the run."; + } + const first = thread.messages[0]; + const preview = first.content.length > 96 ? `${first.content.slice(0, 96)}...` : first.content; + return `${thread.messages.length} message${thread.messages.length === 1 ? "" : "s"} · ${preview}`; +} + +function participantLabels(thread: CommunicationThreadState): string[] { + const ids = new Set([thread.agentAId, thread.agentBId]); + for (const message of thread.messages) { + ids.add(message.fromAgentId); + ids.add(message.toAgentId); + } + return [...ids].filter(Boolean).map(speakerLabel); +} + +function messageAlignment(message: CommunicationThreadState["messages"][number], index: number) { + return index % 2 === 0 || message.fromAgentId === "parent" ? "justify-start" : "justify-end"; } export function CommunicationPanel({ @@ -22,54 +45,169 @@ export function CommunicationPanel({ }: { threads: CommunicationThreadState[]; }) { + const sortedThreads = useMemo( + () => + [...threads].sort( + (a, b) => + Date.parse(a.createdAt) - Date.parse(b.createdAt) || + a.topic.localeCompare(b.topic), + ), + [threads], + ); + const [selectedThreadId, setSelectedThreadId] = useState( + sortedThreads[0]?.id ?? null, + ); + + useEffect(() => { + if (sortedThreads.length === 0) { + setSelectedThreadId(null); + return; + } + if (!selectedThreadId || !sortedThreads.some((thread) => thread.id === selectedThreadId)) { + setSelectedThreadId(sortedThreads[0].id); + } + }, [selectedThreadId, sortedThreads]); + if (threads.length === 0) { return ( -
-

No communication yet

+
+

No communication threads yet

Messages will appear here as threads evolve.

); } + const selectedThread = + sortedThreads.find((thread) => thread.id === selectedThreadId) ?? sortedThreads[0] ?? null; + const selectedMessages = selectedThread + ? [...selectedThread.messages].sort((a, b) => a.sequenceNum - b.sequenceNum) + : []; + return ( -
- {threads.map((thread) => ( -
-
-
-
-
{thread.topic}
-
- {speakerLabel(thread.agentAId)} ↔ {speakerLabel(thread.agentBId)} +
+
+ {sortedThreads.map((thread) => { + const selected = thread.id === selectedThread?.id; + const participants = participantLabels(thread); + return ( +
-
- {thread.messages.map((message) => ( -
-
- {speakerLabel(message.fromAgentId)} - {formatTime(message.createdAt)} +
+ {formatTime(thread.createdAt)} + {formatTime(thread.updatedAt)} +
+ + ); + })} +
+ +
+ {selectedThread && ( + <> +
+
+
+
+ {selectedThread.topic} +
+
+ Started {formatTime(selectedThread.createdAt)} + {selectedThread.taskId ? ` · linked task ${selectedThread.taskId}` : ""} +
-

- {message.content} -

+ + {selectedMessages.length} message{selectedMessages.length === 1 ? "" : "s"} +
- ))} -
-
- ))} +

+ {threadSummary(selectedThread)} +

+
+ +
+ {selectedMessages.length === 0 && ( +
+ No messages are visible at this point in the run. +
+ )} + {selectedMessages.map((message, index) => ( +
+
+
+ + {speakerLabel(message.fromAgentId)} + + + {formatTime(message.createdAt)} + +
+

+ {message.content} +

+
+ #{message.sequenceNum} + {message.taskId && task {message.taskId}} + {message.taskExecutionId && ( + exec {message.taskExecutionId} + )} +
+
+
+ ))} +
+ + )} +
); } diff --git a/ergon-dashboard/src/components/panels/EvaluationPanel.tsx b/ergon-dashboard/src/components/panels/EvaluationPanel.tsx index d8fe29c4..66f90111 100644 --- a/ergon-dashboard/src/components/panels/EvaluationPanel.tsx +++ b/ergon-dashboard/src/components/panels/EvaluationPanel.tsx @@ -6,6 +6,20 @@ function formatPercent(score: number): string { return `${(score * 100).toFixed(1)}%`; } +function EvaluationCriteriaEmpty({ detail }: { detail: string }) { + return ( +
+

+ No evaluation criteria recorded yet +

+

{detail}

+
+ ); +} + export function EvaluationPanel({ evaluation, }: { @@ -13,10 +27,7 @@ export function EvaluationPanel({ }) { if (!evaluation) { return ( -
-

No evaluation yet

-

Judgment surfaces will update when evaluation arrives.

-
+ ); } @@ -49,34 +60,38 @@ export function EvaluationPanel({
-
- {evaluation.criterionResults.map((criterion) => ( -
-
-
-
- {criterion.stageName}: {criterion.criterionDescription} + {evaluation.criterionResults.length === 0 ? ( + + ) : ( +
+ {evaluation.criterionResults.map((criterion) => ( +
+
+
+
+ {criterion.stageName}: {criterion.criterionDescription} +
+
+ {criterion.criterionType} +
-
- {criterion.criterionType} +
+ {criterion.score} / {criterion.maxScore}
-
- {criterion.score} / {criterion.maxScore} -
+ {criterion.feedback ? ( +

+ {criterion.feedback} +

+ ) : null}
- {criterion.feedback ? ( -

- {criterion.feedback} -

- ) : null} -
- ))} -
+ ))} +
+ )}
); } diff --git a/ergon-dashboard/src/components/panels/ResourcePanel.tsx b/ergon-dashboard/src/components/panels/ResourcePanel.tsx index 61ea576a..70ff7d02 100644 --- a/ergon-dashboard/src/components/panels/ResourcePanel.tsx +++ b/ergon-dashboard/src/components/panels/ResourcePanel.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { ResourceViewerDialog } from "@/components/viewers/ResourceViewerDialog"; import { ResourceState } from "@/lib/types"; +import { formatDate } from "@/lib/timeFormat"; interface ResourcePanelProps { resources: ResourceState[]; @@ -41,7 +42,7 @@ function formatRelativeTime(timestamp: string): string { if (diffSeconds < 60) return "just now"; if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`; if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`; - return time.toLocaleDateString(); + return formatDate(time); } /** diff --git a/ergon-dashboard/src/components/panels/SandboxPanel.tsx b/ergon-dashboard/src/components/panels/SandboxPanel.tsx index 3f86c1ac..48eb824c 100644 --- a/ergon-dashboard/src/components/panels/SandboxPanel.tsx +++ b/ergon-dashboard/src/components/panels/SandboxPanel.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { SandboxState, SandboxCommandState } from "@/lib/types"; +import { formatClockTimeSeconds } from "@/lib/timeFormat"; interface SandboxPanelProps { sandbox: SandboxState | undefined; @@ -30,13 +31,7 @@ function formatDuration(ms: number | null): string { * Format timestamp to time string. */ function formatTime(timestamp: string): string { - const date = new Date(timestamp); - return date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + return formatClockTimeSeconds(timestamp); } interface CommandItemProps { diff --git a/ergon-dashboard/src/components/run/RunWorkspacePage.tsx b/ergon-dashboard/src/components/run/RunWorkspacePage.tsx index 9d8b510d..378bda4a 100644 --- a/ergon-dashboard/src/components/run/RunWorkspacePage.tsx +++ b/ergon-dashboard/src/components/run/RunWorkspacePage.tsx @@ -5,21 +5,21 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { DAGCanvas } from "@/components/dag/DAGCanvas"; import { StatusBadge } from "@/components/common/StatusBadge"; -import { RunStatusBar } from "@/components/run/RunStatusBar"; + import { UnifiedEventStream } from "@/components/run/UnifiedEventStream"; import { TaskWorkspace } from "@/components/workspace/TaskWorkspace"; import { ActivityStackTimeline } from "@/features/activity/components/ActivityStackTimeline"; import { buildRunActivities } from "@/features/activity/buildRunActivities"; +import { resolveActivitySnapshotSequence } from "@/features/activity/snapshotSequence"; import type { RunActivity } from "@/features/activity/types"; import { parseGraphMutationDtoArray, type GraphMutationDto, } from "@/features/graph/contracts/graphMutations"; -import { replayToSequence } from "@/features/graph/state/graphMutationReducer"; +import { createReplayInitialState, replayToSequence } from "@/features/graph/state/graphMutationReducer"; import { useCohortDetail } from "@/hooks/useCohortDetail"; import { useRunState } from "@/hooks/useRunState"; import { buildRunEvents } from "@/lib/runEvents"; -import type { WorkflowRunState } from "@/lib/types"; import { CohortDetail, RunLifecycleStatus, SerializedWorkflowRunState, TaskStatus } from "@/lib/types"; function formatSeconds(value: number | null): string { @@ -50,11 +50,13 @@ export function RunWorkspacePage({ cohortId, initialRunState = null, initialCohortDetail = null, + ssrError = null, }: { runId: string; cohortId?: string; initialRunState?: SerializedWorkflowRunState | null; initialCohortDetail?: CohortDetail | null; + ssrError?: string | null; }) { const [selectedTaskId, setSelectedTaskId] = useState(null); const [selectedActivityId, setSelectedActivityId] = useState(null); @@ -64,63 +66,72 @@ export function RunWorkspacePage({ const { runState, isLoading, error, isSubscribed } = useRunState(runId, initialRunState); const { detail } = useCohortDetail(cohortId ?? "", initialCohortDetail); - // Timeline playback state - const [timelineMode, setTimelineMode] = useState<"live" | "timeline">("live"); - const [currentSequence, setCurrentSequence] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [playbackSpeed, setPlaybackSpeed] = useState(1); + // A null snapshot means the graph follows live state; a sequence replays + // mutations to that point. + const [snapshotSequence, setSnapshotSequence] = useState(null); + const currentSequence = snapshotSequence ?? 0; const [mutations, setMutations] = useState([]); - const snapshotCache = useRef(new Map()); const requestedSequenceRef = useRef(null); + const pendingActivityResolutionRef = useRef(null); + const selectedActivityIdRef = useRef(null); + const mutationsLoadedRef = useRef(false); + + useEffect(() => { + selectedActivityIdRef.current = selectedActivityId; + }, [selectedActivityId]); - // Fetch mutations when entering timeline mode + // Fetch mutations once per run load so snapshot selection is always ready. useEffect(() => { - if (timelineMode !== "timeline") return; let cancelled = false; + mutationsLoadedRef.current = false; + pendingActivityResolutionRef.current = null; fetch(`/api/runs/${runId}/mutations`) .then((res) => res.json()) .then((data) => { if (cancelled) return; const parsed = parseGraphMutationDtoArray(data); + mutationsLoadedRef.current = true; setMutations(parsed); - snapshotCache.current.clear(); const requestedSequence = requestedSequenceRef.current; requestedSequenceRef.current = null; - const defaultMutation = parsed[parsed.length - 1] ?? null; - const requestedMutation = - requestedSequence === null - ? null - : nearestMutationAtOrBefore(parsed, requestedSequence); - setCurrentSequence((requestedMutation ?? defaultMutation)?.sequence ?? 0); + if (requestedSequence !== null) { + setSnapshotSequence(nearestMutationAtOrBefore(parsed, requestedSequence)?.sequence ?? null); + return; + } + + const pendingActivity = pendingActivityResolutionRef.current; + pendingActivityResolutionRef.current = null; + if (pendingActivity && selectedActivityIdRef.current === pendingActivity.id) { + const sequence = resolveActivitySnapshotSequence(pendingActivity, parsed); + const resolvedSequence = + sequence === null + ? null + : (nearestMutationAtOrBefore(parsed, sequence)?.sequence ?? sequence); + setSnapshotSequence(resolvedSequence); + } }) .catch(() => { - if (!cancelled) setMutations([]); + if (cancelled) return; + mutationsLoadedRef.current = true; + pendingActivityResolutionRef.current = null; + setMutations([]); }); return () => { cancelled = true; }; - }, [timelineMode, runId]); + }, [runId]); - // Build display state: replay for timeline mode, live state otherwise + // Build display state: replay only for an explicit snapshot; otherwise live. const displayState = useMemo(() => { - if (timelineMode === "live" || mutations.length === 0) return runState; + if (snapshotSequence === null || mutations.length === 0) return runState; if (!runState) return runState; - const emptyState: WorkflowRunState = { - ...runState, - tasks: new Map(), - totalTasks: 0, - totalLeafTasks: 0, - completedTasks: 0, - runningTasks: 0, - failedTasks: 0, - }; + const replayBaseState = createReplayInitialState(runState, mutations, snapshotSequence); return replayToSequence( mutations, - currentSequence, - emptyState, - snapshotCache.current, + snapshotSequence, + replayBaseState, ); - }, [timelineMode, runState, mutations, currentSequence]); + }, [runState, mutations, snapshotSequence]); const runRow = useMemo(() => { if (!cohortId || !detail) return null; @@ -132,9 +143,9 @@ export function RunWorkspacePage({ return displayState.tasks.get(selectedTaskId) ?? null; }, [displayState, selectedTaskId]); - // D7: status counts for the RunStatusBar. Only leaf tasks so the totals + // Status counts shown in the run header. Only leaf tasks so the totals // match the "units of work" the user is tracking (parents double-count). - const { leafStatusCounts, leafTotal } = useMemo(() => { + const { leafStatusCounts } = useMemo(() => { const empty: Record = { [TaskStatus.PENDING]: 0, [TaskStatus.READY]: 0, @@ -153,36 +164,42 @@ export function RunWorkspacePage({ return { leafStatusCounts: empty, leafTotal: total }; }, [displayState]); - // D4: Unified event log — derived from displayState so timeline scrubbing - // trims the feed in lockstep. + // D4: Unified event log for the replayed inspector view. const events = useMemo(() => buildRunEvents(displayState), [displayState]); + // Trace spans are an immutable map of the full run. Replay moves the cursor + // over this map; it should not relayout or clip completed spans. + const traceEvents = useMemo(() => buildRunEvents(runState), [runState]); const activities = useMemo( () => buildRunActivities({ - runState: displayState, - events, + runState, + events: traceEvents, mutations, - currentSequence: timelineMode === "timeline" ? currentSequence : null, + currentSequence: snapshotSequence, }), - [displayState, events, mutations, timelineMode, currentSequence], + [runState, traceEvents, mutations, snapshotSequence], + ); + + const selectedActivity = useMemo( + () => activities.find((activity) => activity.id === selectedActivityId) ?? null, + [activities, selectedActivityId], ); const selectedTimelineTime = useMemo(() => { - if (timelineMode !== "timeline") return null; - return nearestMutationAtOrBefore(mutations, currentSequence)?.created_at ?? null; - }, [timelineMode, mutations, currentSequence]); + if (snapshotSequence === null) return null; + return nearestMutationAtOrBefore(mutations, snapshotSequence)?.created_at ?? null; + }, [mutations, snapshotSequence]); const highlightedTaskIds = useMemo(() => { const ids = new Set(); if (selectedTaskId) ids.add(selectedTaskId); - const selectedActivity = activities.find((activity) => activity.id === selectedActivityId); if (selectedActivity?.taskId) ids.add(selectedActivity.taskId); return ids; - }, [activities, selectedActivityId, selectedTaskId]); + }, [selectedActivity, selectedTaskId]); - // D7: keyboard shortcuts — Esc closes selection, `t` toggles timeline, - // `e` toggles event stream, `1-6` filters by lifecycle status. + // D7: keyboard shortcuts — Esc unwinds UI state, `e` toggles event stream, + // `1-6` filters by lifecycle status. useEffect(() => { const STATUS_ORDER: TaskStatus[] = [ TaskStatus.PENDING, @@ -200,20 +217,36 @@ export function RunWorkspacePage({ return; } } + if (e.key === "Escape") { - if (selectedTaskId) setSelectedTaskId(null); - else if (statusFilter) setStatusFilter(null); - return; - } - if (e.key === "t" || e.key === "T") { - setTimelineMode((prev) => (prev === "live" ? "timeline" : "live")); - if (timelineMode === "timeline") setIsPlaying(false); + if (selectedTaskId) { setSelectedTaskId(null); return; } + if (snapshotSequence !== null) { setSnapshotSequence(null); return; } + if (statusFilter) { setStatusFilter(null); return; } return; } + if (e.key === "e" || e.key === "E") { setIsStreamOpen((prev) => !prev); return; } + + if (e.key === "ArrowLeft" && snapshotSequence !== null) { + const idx = mutations.findIndex((m) => m.sequence === snapshotSequence); + if (idx > 0) setSnapshotSequence(mutations[idx - 1].sequence); + return; + } + if (e.key === "ArrowRight" && snapshotSequence !== null) { + const idx = mutations.findIndex((m) => m.sequence === snapshotSequence); + if (idx >= 0 && idx < mutations.length - 1) setSnapshotSequence(mutations[idx + 1].sequence); + return; + } + + if ((e.key === "d" || e.key === "D") && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + if (selectedTaskId) setSelectedTaskId(null); + return; + } + const idx = Number(e.key) - 1; if (!Number.isNaN(idx) && idx >= 0 && idx < STATUS_ORDER.length) { const next = STATUS_ORDER[idx]; @@ -222,7 +255,7 @@ export function RunWorkspacePage({ }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [selectedTaskId, statusFilter, timelineMode]); + }, [selectedTaskId, statusFilter, mutations, snapshotSequence]); useEffect(() => { if (!selectedTaskId || !displayState) return; @@ -237,22 +270,29 @@ export function RunWorkspacePage({ const handleTaskClick = (taskId: string) => { setSelectionNotice(null); + pendingActivityResolutionRef.current = null; + selectedActivityIdRef.current = null; setSelectedActivityId(null); setSelectedTaskId((prev) => (prev === taskId ? null : taskId)); }; const handleSequenceChange = (sequence: number) => { + pendingActivityResolutionRef.current = null; const mutation = nearestMutationAtOrBefore(mutations, sequence); - setCurrentSequence(mutation?.sequence ?? sequence); + setSnapshotSequence(mutation?.sequence ?? sequence); }; const handleActivityClick = (activity: RunActivity) => { setSelectionNotice(null); + requestedSequenceRef.current = null; + selectedActivityIdRef.current = activity.id; setSelectedActivityId(activity.id); - if (activity.sequence !== null) { - requestedSequenceRef.current = activity.sequence; - if (timelineMode !== "timeline") setTimelineMode("timeline"); - handleSequenceChange(activity.sequence); + const sequence = resolveActivitySnapshotSequence(activity, mutations); + if (sequence !== null) { + handleSequenceChange(sequence); + } else { + setSnapshotSequence(null); + pendingActivityResolutionRef.current = mutationsLoadedRef.current ? null : activity; } if (activity.taskId) { setSelectedTaskId(activity.taskId); @@ -260,139 +300,135 @@ export function RunWorkspacePage({ }; return ( -
+
+ {/* Run header strip */}
-
-
- - - Ergon - -
- - Cohorts - +
+
+ Cohorts + {cohortId && ( <> - / {detail?.summary.name ?? "Cohort"} + )} - / - {runId.slice(0, 8)}... -
- -
-

- {runState?.name ?? runRow?.run_id ?? "Run"} -

- -
+ {runId.slice(0, 8)}…
+
+

+ {runState?.name ?? runRow?.run_id ?? "Run"} +

+ + + {snapshotSequence === null ? "live" : `snapshot · seq ${snapshotSequence}`} · {formatSeconds(runState?.durationSeconds ?? null)} + +
+
-
-
- - Tasks {runState?.totalTasks ?? "—"} - - - Turns {runState?.completedTasks ?? 0} +
+ {/* Key metrics */} +
+
+
Tasks
+ + {leafStatusCounts[TaskStatus.COMPLETED]}·{leafStatusCounts[TaskStatus.RUNNING]}·{leafStatusCounts[TaskStatus.READY]}·{leafStatusCounts[TaskStatus.PENDING]} - - Score {formatPercent(runState?.finalScore ?? runRow?.final_score ?? null)} +
+
+
Tokens
+ +
+
+
Cost
+ +
+
+
Score
+ + {formatPercent(runState?.finalScore ?? runRow?.final_score ?? null)}
-
- {(["live", "timeline"] as const).map((mode) => { - const active = timelineMode === mode; - return ( - - ); - })} -
-
- {leafTotal > 0 && ( -
- -
- )} - - {error && ( -
- {error} -
- )} + + + +
-
+ Server-side error: + {ssrError} +
+ )} + + {error && !ssrError && ( +
+ {error} +
+ )} + +
{selectionNotice && (
{selectionNotice}
)}
0 ? 300 : 0, + paddingRight: isInspectorOpen ? 476 : 0, + }} > 0 && (
setIsPlaying((p) => !p)} - speed={playbackSpeed} - onSpeedChange={setPlaybackSpeed} onActivityClick={handleActivityClick} />
@@ -429,7 +460,7 @@ export function RunWorkspacePage({ {isStreamOpen && events.length > 0 && (
{ - if (timelineMode !== "timeline") setTimelineMode("timeline"); requestedSequenceRef.current = seq; handleSequenceChange(seq); }} @@ -451,7 +481,7 @@ export function RunWorkspacePage({ {isInspectorOpen ? (
setSelectedTaskId(null)} onJumpToSequence={(seq) => { - if (timelineMode !== "timeline") setTimelineMode("timeline"); requestedSequenceRef.current = seq; handleSequenceChange(seq); }} selectedTime={selectedTimelineTime} - selectedSequence={timelineMode === "timeline" ? currentSequence : null} + selectedSequence={snapshotSequence} + selectedActivity={selectedActivity} />
) : (
-
+
Task inspection
-

- Click node {"->"} workspace drawer +

+ Click node → workspace drawer

State, outputs, turns, and evals appear scoped to the selected sequence.

{selectedTask && ( -
- Ready to inspect {selectedTask.name}. +
+ Ready to inspect {selectedTask.name}.
)}
diff --git a/ergon-dashboard/src/components/run/UnifiedEventStream.tsx b/ergon-dashboard/src/components/run/UnifiedEventStream.tsx index 8a93eecd..92f6df62 100644 --- a/ergon-dashboard/src/components/run/UnifiedEventStream.tsx +++ b/ergon-dashboard/src/components/run/UnifiedEventStream.tsx @@ -21,19 +21,12 @@ import { type RunEvent, type RunEventKind, } from "@/lib/runEvents"; +import { formatClockTimeMs } from "@/lib/timeFormat"; import { TransitionChip } from "@/components/common/TransitionChip"; function formatTime(iso: string): string { - try { - return new Date(iso).toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - fractionalSecondDigits: 3, - }); - } catch { - return iso; - } + const label = formatClockTimeMs(iso); + return label === "—" ? iso : label; } function formatRelative(iso: string, anchorMs: number | null): string { diff --git a/ergon-dashboard/src/components/shell/Topbar.tsx b/ergon-dashboard/src/components/shell/Topbar.tsx new file mode 100644 index 00000000..01517a1a --- /dev/null +++ b/ergon-dashboard/src/components/shell/Topbar.tsx @@ -0,0 +1,96 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const NAV_ITEMS = [ + { label: "Cohorts", href: "/" }, + { label: "Runs", href: "/runs" }, + { label: "Training", href: "/training" }, + { label: "Models", href: "/models" }, + { label: "Settings", href: "/settings" }, +] as const; + +function isActive(href: string, pathname: string): boolean { + if (href === "/") { + return pathname === "/" || pathname.startsWith("/cohorts"); + } + if (href === "/runs") { + return pathname.startsWith("/run/") || pathname.startsWith("/runs"); + } + return pathname.startsWith(href); +} + +export function Topbar() { + const pathname = usePathname(); + + return ( +
+
+ {/* Logo + wordmark */} + + + + + Ergon + + + {/* Navigation — hidden on small screens */} + +
+ +
+ {/* Search — hidden on smaller viewports */} +
+ + Search cohorts, runs, tasks… + + ⌘K + +
+ + {/* User avatar */} +
+ JM +
+
+
+ ); +} diff --git a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx index 23489a05..139e637c 100644 --- a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx +++ b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx @@ -1,5 +1,7 @@ "use client"; +import { useEffect, useRef, useState } from "react"; + import { useTaskDetails } from "@/hooks/useTaskDetails"; import { StatusBadge } from "@/components/common/StatusBadge"; import { CommunicationPanel } from "@/components/panels/CommunicationPanel"; @@ -8,12 +10,69 @@ import { ResourcePanel } from "@/components/panels/ResourcePanel"; import { SandboxPanel } from "@/components/panels/SandboxPanel"; import { TaskTransitionLog } from "@/components/workspace/TaskTransitionLog"; import { ContextEventLog } from "@/features/graph/components/ContextEventLog"; +import type { RunActivity } from "@/features/activity/types"; import type { WorkflowRunState } from "@/lib/types"; +import { formatClockTime } from "@/lib/timeFormat"; import { formatTaskWallTimestamp } from "@/features/graph/utils/taskTiming"; import { filterTaskEvidenceForTime } from "./filterTaskEvidenceForTime"; function EmptySection({ message }: { message: string }) { - return
{message}
; + return
{message}
; +} + +const ACTIVITY_KIND_TITLE: Record = { + execution: "Execution", + graph: "Graph mutation", + message: "Message", + artifact: "Artifact", + evaluation: "Evaluation", + context: "Context event", + sandbox: "Sandbox", +}; + +function ActivityDetail({ activity }: { activity: RunActivity }) { + const metadata = Object.entries(activity.metadata) + .filter(([, value]) => value !== null && value !== "") + .slice(0, 4); + const debugPayload = JSON.stringify(activity.debug, null, 2); + + return ( +
+
+ Selected activity +
+
+ {ACTIVITY_KIND_TITLE[activity.kind]}: {activity.label} +
+
+ Band: {activity.band} + Source: {activity.sourceKind} + Actor: {activity.actor ?? "—"} + Started: {formatClockTime(activity.startAt)} + Sequence: {activity.sequence ?? "—"} + Task: {activity.lineage.taskId ?? "—"} + Execution: {activity.lineage.taskExecutionId ?? "—"} + Sandbox: {activity.lineage.sandboxId ?? "—"} + {activity.endAt && Ended: {formatClockTime(activity.endAt)}} + {metadata.map(([key, value]) => ( + + {key}: {String(value)} + + ))} +
+
+ + Raw JSON + + + {debugPayload} + +
+
+ ); } function WorkspaceSection({ @@ -27,10 +86,10 @@ function WorkspaceSection({ }) { return (
-

+

{title}

{children} @@ -38,6 +97,48 @@ function WorkspaceSection({ ); } +type WorkspaceTabId = "overview" | "actions" | "communication" | "outputs" | "transitions" | "evaluation"; + +const WORKSPACE_TABS: Array<{ + id: WorkspaceTabId; + label: string; + testId: string; +}> = [ + { id: "overview", label: "Overview", testId: "workspace-tab-overview" }, + { id: "actions", label: "Actions", testId: "workspace-tab-actions" }, + { id: "communication", label: "Communication", testId: "workspace-tab-communication" }, + { id: "outputs", label: "Outputs", testId: "workspace-tab-outputs" }, + { id: "transitions", label: "Transitions", testId: "workspace-tab-transitions" }, + { id: "evaluation", label: "Evaluation", testId: "workspace-tab-evaluation" }, +]; + +function workspaceTabButtonId(id: WorkspaceTabId) { + return `workspace-tab-button-${id}`; +} + +function workspaceTabPanelId(id: WorkspaceTabId) { + return `workspace-tab-panel-${id}`; +} + +function WorkspaceTabPanel({ + tabId, + children, +}: { + tabId: WorkspaceTabId; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + export function TaskWorkspace({ runState, taskId, @@ -46,6 +147,7 @@ export function TaskWorkspace({ onJumpToSequence, selectedTime = null, selectedSequence = null, + selectedActivity = null, }: { runState: WorkflowRunState | null; taskId: string | null; @@ -54,9 +156,50 @@ export function TaskWorkspace({ onJumpToSequence?: (sequence: number) => void; selectedTime?: string | null; selectedSequence?: number | null; + selectedActivity?: RunActivity | null; }) { const { task, resources, executions, sandbox, threads, evaluation, dependencies, isLoading } = useTaskDetails(runState, taskId); + const [activeTab, setActiveTab] = useState("overview"); + const tabButtonRefs = useRef>({ + overview: null, + actions: null, + communication: null, + outputs: null, + transitions: null, + evaluation: null, + }); + + useEffect(() => { + setActiveTab("overview"); + }, [taskId]); + + function activateTab(tabId: WorkspaceTabId, shouldFocus = false) { + setActiveTab(tabId); + if (shouldFocus) { + requestAnimationFrame(() => tabButtonRefs.current[tabId]?.focus()); + } + } + + function handleTabKeyDown(event: React.KeyboardEvent, tabId: WorkspaceTabId) { + const currentIndex = WORKSPACE_TABS.findIndex((tab) => tab.id === tabId); + if (currentIndex === -1) return; + + let nextIndex: number | null = null; + if (event.key === "ArrowRight") { + nextIndex = (currentIndex + 1) % WORKSPACE_TABS.length; + } else if (event.key === "ArrowLeft") { + nextIndex = (currentIndex - 1 + WORKSPACE_TABS.length) % WORKSPACE_TABS.length; + } else if (event.key === "Home") { + nextIndex = 0; + } else if (event.key === "End") { + nextIndex = WORKSPACE_TABS.length - 1; + } + + if (nextIndex === null) return; + event.preventDefault(); + activateTab(WORKSPACE_TABS[nextIndex].id, true); + } const contextEvents = taskId && runState ? (runState.contextEventsByTask.get(taskId) ?? []) : []; const filteredEvidence = filterTaskEvidenceForTime({ @@ -72,7 +215,7 @@ export function TaskWorkspace({ if (!taskId) { return (
Select a task from the graph to open the focused task workspace. @@ -83,7 +226,7 @@ export function TaskWorkspace({ if (isLoading) { return (
Loading task workspace... @@ -102,37 +245,26 @@ export function TaskWorkspace({ ); } - const primarySection = - filteredEvidence.resources.length > 0 - ? "outputs" - : filteredEvidence.evaluation - ? "evaluation" - : filteredEvidence.threads.length > 0 - ? "communication" - : filteredEvidence.sandbox - ? "sandbox" - : "overview"; - const started = formatTaskWallTimestamp(task.startedAt); const ended = formatTaskWallTimestamp(task.completedAt); return ( -
+
-
+
Task workspace
-

{task.name}

+

{task.name}

{selectedSequence !== null && ( Viewing seq {selectedSequence} @@ -142,14 +274,14 @@ export function TaskWorkspace({ )}
-
+
Worker: {task.assignedWorkerName ?? "—"} Level: {task.level} Leaf task: {task.isLeaf ? "yes" : "no"} @@ -161,7 +293,7 @@ export function TaskWorkspace({ @@ -175,7 +307,7 @@ export function TaskWorkspace({ @@ -185,42 +317,51 @@ export function TaskWorkspace({
{task.description && ( -

{task.description}

+

{task.description}

)} + {selectedActivity && }
-
- - - +
+ {WORKSPACE_TABS.map((tab) => { + const selected = activeTab === tab.id; - - - + return ( + + ); + })} +
-
- {primarySection === "outputs" && ( - - - - )} - {primarySection === "evaluation" && ( - - - - )} - {primarySection === "communication" && ( - - - - )} - {primarySection === "sandbox" && ( - - - - )} - {primarySection === "overview" && ( - +
+ {activeTab === "overview" && ( + +
Waiting on
@@ -248,100 +389,90 @@ export function TaskWorkspace({
- )} -
- -
- -
-
-
Waiting on
- {dependencies.waitingOn.length === 0 ? ( - - ) : ( -
    - {dependencies.waitingOn.map((dep) => ( -
  • {dep.name}
  • - ))} -
- )} -
-
-
Blocking
- {dependencies.blocking.length === 0 ? ( - - ) : ( -
    - {dependencies.blocking.map((dep) => ( -
  • {dep.name}
  • - ))} -
- )} -
-
-
+ + )} - - {filteredEvidence.executions.length === 0 ? ( - - ) : ( + {activeTab === "actions" && ( + +
- {filteredEvidence.executions.map((execution) => ( -
-
-
- Attempt {execution.attemptNumber} -
- -
-
- Agent: {execution.agentName ?? "—"} - - Started: {execution.startedAt ? new Date(execution.startedAt).toLocaleTimeString() : "—"} - - - Completed: {execution.completedAt ? new Date(execution.completedAt).toLocaleTimeString() : "—"} - + + + + {filteredEvidence.executions.length === 0 ? ( + + ) : ( +
+ {filteredEvidence.executions.map((execution) => ( +
+
+
+ Attempt {execution.attemptNumber} +
+ +
+
+ Agent: {execution.agentName ?? "—"} + + Started: {execution.startedAt ? formatClockTime(execution.startedAt) : "—"} + + + Completed: {execution.completedAt ? formatClockTime(execution.completedAt) : "—"} + +
+ {execution.errorMessage && ( +
+ {execution.errorMessage} +
+ )} +
+ ))}
- {execution.errorMessage && ( -
- {execution.errorMessage} -
- )} -
- ))} + )} + + + + +
- )} - + + + )} - {primarySection !== "communication" && ( + {activeTab === "communication" && ( + - )} + + )} - {primarySection !== "outputs" && ( + {activeTab === "outputs" && ( + - )} + + )} - {primarySection !== "evaluation" && ( - - + {activeTab === "transitions" && ( + + + - )} + + )} - {primarySection !== "sandbox" && ( - - + {activeTab === "evaluation" && ( + + + - )} - -
+
+ )}
); diff --git a/ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts b/ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts index cd9f3cfd..13d9985e 100644 --- a/ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts +++ b/ergon-dashboard/src/components/workspace/filterTaskEvidenceForTime.test.ts @@ -40,3 +40,42 @@ test("filterTaskEvidenceForTime returns unfiltered task evidence in live mode", assert.equal(filtered.resources.length, 1); assert.equal(filtered.sandbox?.commands.length, 1); }); + +test("filterTaskEvidenceForTime keeps only thread messages visible at selected time", () => { + const runState = deserializeRunState(fixture.runState); + const thread = runState.threads[0]; + const filtered = filterTaskEvidenceForTime({ + resources: [], + executions: [], + sandbox: undefined, + threads: [ + { + ...thread, + createdAt: "2026-04-26T12:00:10.000Z", + messages: [ + { + ...thread.messages[0], + id: "visible-message", + content: "visible", + createdAt: "2026-04-26T12:00:20.000Z", + }, + { + ...thread.messages[0], + id: "future-message", + content: "future", + createdAt: "2026-04-26T12:00:30.000Z", + }, + ], + }, + ], + evaluation: null, + contextEvents: [], + selectedTime: "2026-04-26T12:00:25.000Z", + }); + + assert.equal(filtered.threads.length, 1); + assert.deepEqual( + filtered.threads[0].messages.map((message) => message.content), + ["visible"], + ); +}); diff --git a/ergon-dashboard/src/features/activity/buildRunActivities.test.ts b/ergon-dashboard/src/features/activity/buildRunActivities.test.ts index d0ab4c7a..9183939a 100644 --- a/ergon-dashboard/src/features/activity/buildRunActivities.test.ts +++ b/ergon-dashboard/src/features/activity/buildRunActivities.test.ts @@ -3,14 +3,168 @@ import test from "node:test"; import fixture from "../../../tests/fixtures/mas-runs/concurrent-mas-run.json"; import { parseGraphMutationDtoArray } from "@/features/graph/contracts/graphMutations"; +import type { RunEvent } from "@/lib/runEvents"; import { buildRunEvents } from "@/lib/runEvents"; import { deserializeRunState } from "@/lib/runState"; +import { TaskStatus, TaskTrigger } from "@/lib/types"; import { buildRunActivities } from "./buildRunActivities"; +import { resolveActivitySnapshotSequence } from "./snapshotSequence"; -test("buildRunActivities derives spans and markers without creating agent lanes", () => { +test("buildRunActivities surfaces semantic activity kinds without creating actor lanes", () => { const runState = deserializeRunState(fixture.runState); const mutations = parseGraphMutationDtoArray(fixture.mutations); - const events = buildRunEvents(runState); + const noisyTaskId = "10000000-0000-4000-8000-000000000002"; + runState.sandboxesByTask.set(noisyTaskId, { + sandboxId: "sandbox-noisy", + taskId: noisyTaskId, + template: "python", + timeoutMinutes: 30, + status: "closed", + createdAt: "2025-01-01T00:00:05.000Z", + closedAt: "2025-01-01T00:00:20.000Z", + closeReason: "completed", + commands: [ + { + command: "pnpm test --verbose", + stdout: null, + stderr: null, + exitCode: 0, + durationMs: 1000, + timestamp: "2025-01-01T00:00:10.000Z", + }, + ], + }); + runState.executionsByTask.set(noisyTaskId, [ + { + id: "execution-noisy", + taskId: noisyTaskId, + attemptNumber: 1, + status: TaskStatus.COMPLETED, + agentId: "agent-a", + agentName: "worker-1", + startedAt: "2025-01-01T00:00:04.000Z", + completedAt: "2025-01-01T00:00:16.000Z", + finalAssistantMessage: null, + outputResourceIds: [], + errorMessage: null, + score: null, + evaluationDetails: {}, + }, + ]); + runState.contextEventsByTask.set(noisyTaskId, [ + { + id: "context-noisy", + taskExecutionId: "execution-noisy", + taskNodeId: noisyTaskId, + workerBindingKey: "worker-1", + sequence: 12, + eventType: "tool_call", + payload: { + event_type: "tool_call", + tool_call_id: "tool-call-noisy", + tool_name: "shell", + args: { command: "pnpm test" }, + turn_id: "turn-noisy", + turn_token_ids: null, + turn_logprobs: null, + }, + createdAt: "2025-01-01T00:00:12.000Z", + startedAt: "2025-01-01T00:00:12.000Z", + completedAt: "2025-01-01T00:00:13.000Z", + }, + ]); + runState.threads = [ + ...runState.threads, + { + id: "thread-noisy", + runId: runState.id, + taskId: noisyTaskId, + topic: "coordination", + agentAId: "agent-a", + agentBId: "agent-b", + createdAt: "2025-01-01T00:00:12.000Z", + updatedAt: "2025-01-01T00:00:12.000Z", + messages: [ + { + id: "message-noisy", + threadId: "thread-noisy", + threadTopic: "coordination", + runId: runState.id, + taskId: noisyTaskId, + taskExecutionId: null, + fromAgentId: "agent-a", + toAgentId: "agent-b", + content: "Verbose coordination message", + sequenceNum: 99, + createdAt: "2025-01-01T00:00:12.000Z", + }, + ], + }, + ]; + const markerEvents: RunEvent[] = [ + { + id: "marker-workflow-started", + kind: "workflow.started", + at: "2025-01-01T00:00:06.000Z", + runName: "Marker workflow", + }, + { + id: "marker-workflow-completed", + kind: "workflow.completed", + at: "2025-01-01T00:00:07.000Z", + status: "completed", + finalScore: 1, + error: null, + }, + { + id: "marker-task-transition", + kind: "task.transition", + at: "2025-01-01T00:00:08.000Z", + taskId: noisyTaskId, + taskName: "Noisy task", + from: TaskStatus.READY, + to: TaskStatus.RUNNING, + trigger: TaskTrigger.WORKER_STARTED, + reason: null, + actor: "worker-1", + }, + { + id: "marker-thread-message", + kind: "thread.message", + at: "2025-01-01T00:00:09.000Z", + taskId: noisyTaskId, + threadId: "thread-noisy", + authorRole: "agent", + preview: "Marker message", + }, + { + id: "marker-task-evaluation", + kind: "task.evaluation", + at: "2025-01-01T00:00:11.000Z", + taskId: noisyTaskId, + score: 0.9, + passed: true, + }, + { + id: "marker-resource-published", + kind: "resource.published", + at: "2025-01-01T00:00:13.000Z", + taskId: noisyTaskId, + name: "artifact.json", + mimeType: "application/json", + sizeBytes: 128, + }, + { + id: "marker-unhandled-mutation", + kind: "unhandled.mutation", + at: "2025-01-01T00:00:14.000Z", + taskId: noisyTaskId, + sequence: 13, + mutationType: "unknown_marker", + note: "Unhandled marker mutation", + }, + ]; + const events = [...buildRunEvents(runState), ...markerEvents]; const activities = buildRunActivities({ runState, @@ -23,7 +177,7 @@ test("buildRunActivities derives spans and markers without creating agent lanes" activities.some( (activity) => activity.kind === "execution" && - activity.taskId === "10000000-0000-4000-8000-000000000002" && + activity.taskId === noisyTaskId && activity.isInstant === false, ), ); @@ -35,10 +189,108 @@ test("buildRunActivities derives spans and markers without creating agent lanes" activity.taskId === "10000000-0000-4000-8000-000000000003", ), ); - assert.ok(activities.some((activity) => activity.kind === "artifact")); - assert.ok(activities.some((activity) => activity.kind === "evaluation")); + assert.deepEqual( + [...new Set(activities.map((activity) => String(activity.kind)))].sort(), + [ + "artifact", + "context", + "evaluation", + "execution", + "graph", + "message", + "sandbox", + ], + ); + assert.ok(activities.some((activity) => String(activity.label).includes("pnpm test"))); + assert.ok(activities.some((activity) => String(activity.label).includes("artifact.json"))); + assert.ok(activities.some((activity) => String(activity.label).includes("tool_call"))); + assert.ok(activities.some((activity) => String(activity.label).includes("Marker message"))); + assert.ok(activities.some((activity) => String(activity.label).includes("Evaluation"))); + assert.ok( + activities.some( + (activity) => + activity.kind === "execution" && + activity.band === "work" && + activity.lineage.taskExecutionId === "execution-noisy", + ), + ); + assert.ok( + activities.some( + (activity) => + activity.kind === "context" && + activity.band === "tools" && + activity.lineage.taskExecutionId === "execution-noisy", + ), + ); + assert.ok( + activities.some( + (activity) => + activity.kind === "message" && + activity.band === "communication" && + activity.lineage.taskId === noisyTaskId, + ), + ); + assert.ok( + activities.some( + (activity) => + activity.kind === "artifact" && + activity.band === "outputs" && + activity.lineage.taskId === noisyTaskId, + ), + ); assert.equal( activities.some((activity) => "laneId" in activity.metadata), false, ); }); + +test("completed trace spans keep full duration when replaying an earlier sequence", () => { + const runState = deserializeRunState(fixture.runState); + const mutations = parseGraphMutationDtoArray(fixture.mutations); + const events = buildRunEvents(runState); + + const activities = buildRunActivities({ + runState, + events, + mutations, + currentSequence: 10, + }); + + const execution = activities.find( + (activity) => activity.id === "execution:30000000-0000-4000-8000-000000000001", + ); + const sandbox = activities.find((activity) => activity.id === "sandbox:sandbox-search"); + const graphMarker = activities.find( + (activity) => activity.kind === "graph" && activity.sequence === 10, + ); + + assert.equal(execution?.startAt, "2026-04-26T12:00:05.000Z"); + assert.equal(execution?.endAt, "2026-04-26T12:00:24.000Z"); + assert.equal(sandbox?.startAt, "2026-04-26T12:00:04.000Z"); + assert.equal(sandbox?.endAt, "2026-04-26T12:00:26.000Z"); + assert.equal(execution?.metadata.openEnded, false); + assert.equal(sandbox?.metadata.openEnded, false); + assert.equal(graphMarker?.debug?.source, "graph.mutation"); +}); + +test("context/tool event sequence does not masquerade as graph replay sequence", () => { + const runState = deserializeRunState(fixture.runState); + const mutations = parseGraphMutationDtoArray(fixture.mutations); + const activities = buildRunActivities({ + runState, + events: buildRunEvents(runState), + mutations, + currentSequence: null, + }); + + const toolActivity = activities.find( + (activity) => activity.id === "context:60000000-0000-4000-8000-000000000001", + ); + + assert.equal(toolActivity?.kind, "context"); + assert.equal(toolActivity?.sequence, null); + assert.equal( + toolActivity ? resolveActivitySnapshotSequence(toolActivity, mutations) : null, + 10, + ); +}); diff --git a/ergon-dashboard/src/features/activity/buildRunActivities.ts b/ergon-dashboard/src/features/activity/buildRunActivities.ts index 7b1e0541..1c65ee3e 100644 --- a/ergon-dashboard/src/features/activity/buildRunActivities.ts +++ b/ergon-dashboard/src/features/activity/buildRunActivities.ts @@ -3,11 +3,10 @@ import type { ContextEventState, ExecutionAttemptState, SandboxCommandState, - SandboxState, WorkflowRunState, } from "@/lib/types"; import type { RunEvent } from "@/lib/runEvents"; -import type { ActivityKind, RunActivity } from "./types"; +import type { RunActivity } from "./types"; export interface BuildRunActivitiesInput { runState: WorkflowRunState | null; @@ -28,28 +27,34 @@ function compareActivity(a: RunActivity, b: RunActivity): number { return a.id.localeCompare(b.id); } -function capEndAt(endAt: string | null, selectedTime: string | null): string | null { - if (!selectedTime || !endAt) return endAt; - return Date.parse(endAt) > Date.parse(selectedTime) ? selectedTime : endAt; -} - function executionLabel(execution: ExecutionAttemptState, run: WorkflowRunState): string { const task = run.tasks.get(execution.taskId); return task?.name ?? `Attempt ${execution.attemptNumber}`; } +function truncate(value: string, length = 64): string { + return value.length > length ? `${value.slice(0, length - 1)}…` : value; +} + +function addMs(timestamp: string, durationMs: number | null): string | null { + if (durationMs === null || durationMs <= 0) return null; + const startMs = Date.parse(timestamp); + if (!Number.isFinite(startMs)) return null; + return new Date(startMs + durationMs).toISOString(); +} + function executionActivities( run: WorkflowRunState, - selectedTime: string | null, ): RunActivity[] { const activities: RunActivity[] = []; for (const executions of run.executionsByTask.values()) { for (const execution of executions) { if (!isFiniteTime(execution.startedAt)) continue; - const endAt = capEndAt(execution.completedAt, selectedTime) ?? selectedTime; + const endAt = execution.completedAt; activities.push({ id: `execution:${execution.id}`, kind: "execution", + band: "work", label: executionLabel(execution, run), taskId: execution.taskId, sequence: null, @@ -62,6 +67,16 @@ function executionActivities( attemptNumber: execution.attemptNumber, status: execution.status, agentId: execution.agentId, + openEnded: endAt === null, + }, + lineage: { + taskId: execution.taskId, + taskExecutionId: execution.id, + agentId: execution.agentId, + }, + debug: { + source: "execution.span", + payload: execution, }, }); } @@ -69,165 +84,221 @@ function executionActivities( return activities; } +function sandboxCommandLabel(command: SandboxCommandState): string { + return `cmd: ${truncate(command.command)}`; +} + function sandboxActivities( run: WorkflowRunState, - selectedTime: string | null, ): RunActivity[] { const activities: RunActivity[] = []; for (const sandbox of run.sandboxesByTask.values()) { - activities.push(sandboxSpanActivity(sandbox, selectedTime)); + if (isFiniteTime(sandbox.createdAt)) { + const endAt = sandbox.closedAt; + activities.push({ + id: `sandbox:${sandbox.sandboxId}`, + kind: "sandbox", + band: "work", + label: `sandbox: ${sandbox.template ?? sandbox.sandboxId}`, + taskId: sandbox.taskId, + sequence: null, + startAt: sandbox.createdAt, + endAt, + isInstant: !endAt || endAt === sandbox.createdAt, + actor: null, + sourceKind: "sandbox.span", + metadata: { + sandboxId: sandbox.sandboxId, + status: sandbox.status, + closeReason: sandbox.closeReason, + openEnded: endAt === null, + }, + lineage: { + taskId: sandbox.taskId, + sandboxId: sandbox.sandboxId, + }, + debug: { + source: "sandbox.span", + payload: { + ...sandbox, + commands: undefined, + }, + }, + }); + } + for (let i = 0; i < sandbox.commands.length; i++) { - activities.push(commandActivity(sandbox, sandbox.commands[i], i)); + const command = sandbox.commands[i]; + if (!isFiniteTime(command.timestamp)) continue; + const endAt = addMs(command.timestamp, command.durationMs); + activities.push({ + id: `sandbox.command:${sandbox.sandboxId}:${i}`, + kind: "sandbox", + band: "tools", + label: sandboxCommandLabel(command), + taskId: sandbox.taskId, + sequence: null, + startAt: command.timestamp, + endAt, + isInstant: !endAt || endAt === command.timestamp, + actor: null, + sourceKind: "sandbox.command", + metadata: { + sandboxId: sandbox.sandboxId, + exitCode: command.exitCode, + durationMs: command.durationMs, + }, + lineage: { + taskId: sandbox.taskId, + sandboxId: sandbox.sandboxId, + }, + debug: { + source: "sandbox.command", + payload: command, + }, + }); } } return activities; } -function sandboxSpanActivity(sandbox: SandboxState, selectedTime: string | null): RunActivity { - const endAt = capEndAt(sandbox.closedAt, selectedTime) ?? selectedTime; - return { - id: `sandbox:${sandbox.sandboxId}`, - kind: "sandbox", - label: sandbox.template ?? sandbox.sandboxId, - taskId: sandbox.taskId, - sequence: null, - startAt: sandbox.createdAt, - endAt, - isInstant: !endAt || endAt === sandbox.createdAt, - actor: null, - sourceKind: "sandbox.span", - metadata: { - sandboxId: sandbox.sandboxId, - status: sandbox.status, - closeReason: sandbox.closeReason, - }, - }; -} - -function commandActivity( - sandbox: SandboxState, - command: SandboxCommandState, - index: number, -): RunActivity { - const startMs = Date.parse(command.timestamp); - const endAt = - command.durationMs != null && Number.isFinite(startMs) - ? new Date(startMs + command.durationMs).toISOString() +function contextLabel(event: ContextEventState): string { + const payloadType = + typeof event.payload === "object" && + event.payload !== null && + "event_type" in event.payload + ? String((event.payload as { event_type?: unknown }).event_type) : null; - return { - id: `sandbox.command:${sandbox.sandboxId}:${index}`, - kind: "sandbox", - label: command.command, - taskId: sandbox.taskId, - sequence: null, - startAt: command.timestamp, - endAt, - isInstant: !endAt, - actor: null, - sourceKind: "sandbox.command", - metadata: { - exitCode: command.exitCode, - durationMs: command.durationMs, - }, - }; + return payloadType ?? event.eventType; } function contextActivities(run: WorkflowRunState): RunActivity[] { const activities: RunActivity[] = []; - for (const [taskId, contextEvents] of run.contextEventsByTask.entries()) { - for (const event of contextEvents) { - activities.push(contextActivity(taskId, event)); + for (const [taskId, events] of run.contextEventsByTask.entries()) { + for (const event of events) { + const startAt = event.startedAt ?? event.createdAt; + if (!isFiniteTime(startAt)) continue; + const endAt = event.completedAt; + activities.push({ + id: `context:${event.id}`, + kind: "context", + band: "tools", + label: contextLabel(event), + taskId, + sequence: null, + startAt, + endAt, + isInstant: !endAt || endAt === startAt, + actor: event.workerBindingKey ?? null, + sourceKind: endAt ? "context.span" : "context.event", + metadata: { + eventId: event.id, + eventType: event.eventType, + contextSequence: event.sequence ?? null, + taskExecutionId: event.taskExecutionId, + }, + lineage: { + taskId, + taskExecutionId: event.taskExecutionId, + workerBindingKey: event.workerBindingKey, + }, + debug: { + source: "context.event", + payload: event, + }, + }); } } return activities; } -function contextActivity(taskId: string, event: ContextEventState): RunActivity { - const label = - typeof event.payload === "object" && - event.payload && - "tool_name" in event.payload - ? String((event.payload as { tool_name?: unknown }).tool_name) - : event.eventType; - return { - id: `context:${event.id}`, - kind: "context", - label, - taskId, - sequence: event.sequence, - startAt: event.startedAt ?? event.createdAt, - endAt: event.completedAt, - isInstant: !event.startedAt || !event.completedAt, - actor: event.workerBindingKey, - sourceKind: "context.event", - metadata: { - eventType: event.eventType, - taskExecutionId: event.taskExecutionId, - }, - }; -} - -function eventKindToActivityKind(event: RunEvent): ActivityKind | null { - switch (event.kind) { - case "thread.message": - return "message"; - case "task.evaluation": - return "evaluation"; - case "resource.published": - return "artifact"; - case "workflow.started": - case "workflow.completed": - case "task.transition": - case "unhandled.mutation": - return "graph"; - case "sandbox.created": - case "sandbox.command": - case "sandbox.closed": - case "context.event": - return null; - } -} - -function eventLabel(event: RunEvent): string { - switch (event.kind) { - case "thread.message": - return event.preview; - case "task.evaluation": - return "Evaluation"; - case "resource.published": - return event.name; - case "workflow.started": - return "Workflow started"; - case "workflow.completed": - return `Workflow ${event.status}`; - case "task.transition": - return `${event.taskName}: ${event.to}`; - case "unhandled.mutation": - return event.mutationType; - default: - return event.kind; - } -} - function eventMarkerActivities(events: RunEvent[]): RunActivity[] { - return events.flatMap((event) => { - const kind = eventKindToActivityKind(event); - if (!kind) return []; - return [ - { - id: `event:${event.id}`, - kind, - label: eventLabel(event), - taskId: event.taskId ?? null, - sequence: event.sequence ?? null, - startAt: event.at, - endAt: null, - isInstant: true, - actor: "actor" in event && typeof event.actor === "string" ? event.actor : null, - sourceKind: event.kind, - metadata: { eventKind: event.kind }, - }, - ]; + return events.flatMap((event): RunActivity[] => { + switch (event.kind) { + case "thread.message": + return [ + { + id: `message:${event.id}`, + kind: "message", + band: "communication", + label: truncate(event.preview), + taskId: event.taskId ?? null, + sequence: event.sequence ?? null, + startAt: event.at, + endAt: null, + isInstant: true, + actor: event.authorRole, + sourceKind: event.kind, + metadata: { + threadId: event.threadId, + }, + lineage: { + taskId: event.taskId ?? null, + threadId: event.threadId, + }, + debug: { + source: event.kind, + payload: event, + }, + }, + ]; + case "resource.published": + return [ + { + id: `artifact:${event.id}`, + kind: "artifact", + band: "outputs", + label: `artifact: ${event.name}`, + taskId: event.taskId ?? null, + sequence: event.sequence ?? null, + startAt: event.at, + endAt: null, + isInstant: true, + actor: null, + sourceKind: event.kind, + metadata: { + mimeType: event.mimeType, + sizeBytes: event.sizeBytes, + }, + lineage: { + taskId: event.taskId ?? null, + }, + debug: { + source: event.kind, + payload: event, + }, + }, + ]; + case "task.evaluation": + return [ + { + id: `evaluation:${event.id}`, + kind: "evaluation", + band: "outputs", + label: `Evaluation ${event.passed === null ? "updated" : event.passed ? "passed" : "failed"}`, + taskId: event.taskId ?? null, + sequence: event.sequence ?? null, + startAt: event.at, + endAt: null, + isInstant: true, + actor: null, + sourceKind: event.kind, + metadata: { + score: event.score, + passed: event.passed, + }, + lineage: { + taskId: event.taskId ?? null, + }, + debug: { + source: event.kind, + payload: event, + }, + }, + ]; + default: + return []; + } }); } @@ -235,6 +306,7 @@ function graphMutationActivities(mutations: GraphMutationDto[]): RunActivity[] { return mutations.map((mutation) => ({ id: `graph:${mutation.id}`, kind: "graph", + band: "graph", label: mutation.mutation_type, taskId: mutation.target_type === "node" ? mutation.target_id : null, sequence: mutation.sequence, @@ -248,19 +320,21 @@ function graphMutationActivities(mutations: GraphMutationDto[]): RunActivity[] { targetType: mutation.target_type, reason: mutation.reason, }, + lineage: { + taskId: mutation.target_type === "node" ? mutation.target_id : null, + }, + debug: { + source: "graph.mutation", + payload: mutation, + }, })); } export function buildRunActivities(input: BuildRunActivitiesInput): RunActivity[] { if (!input.runState) return []; - const selectedMutation = - input.currentSequence == null - ? null - : input.mutations.find((mutation) => mutation.sequence === input.currentSequence); - const selectedTime = selectedMutation?.created_at ?? null; return [ - ...executionActivities(input.runState, selectedTime), - ...sandboxActivities(input.runState, selectedTime), + ...executionActivities(input.runState), + ...sandboxActivities(input.runState), ...contextActivities(input.runState), ...eventMarkerActivities(input.events), ...graphMutationActivities(input.mutations), diff --git a/ergon-dashboard/src/features/activity/components/ActivityBar.tsx b/ergon-dashboard/src/features/activity/components/ActivityBar.tsx index 6a786ae6..e122bd82 100644 --- a/ergon-dashboard/src/features/activity/components/ActivityBar.tsx +++ b/ergon-dashboard/src/features/activity/components/ActivityBar.tsx @@ -4,63 +4,49 @@ import type { ActivityStackItem, ActivityKind, RunActivity } from "@/features/ac const KIND_STYLES: Record< ActivityKind, - { bar: string; marker: string; text: string; label: string; fill: string; stroke: string } + { fill: string; text: string; label: string; legendLabel: string } > = { + graph: { + fill: "oklch(0.78 0.14 305)", + text: "white", + label: "Graph mutation", + legendLabel: "graph mutation", + }, execution: { - bar: "bg-amber-400 border-amber-300 text-[#2a1800]", - marker: "bg-amber-400 text-amber-400", - text: "text-[#2a1800]", + fill: "oklch(0.74 0.16 295)", + text: "white", label: "Execution", - fill: "#fbbf24", - stroke: "#fde68a", - }, - graph: { - bar: "bg-violet-400 border-violet-300 text-[#160b2f]", - marker: "bg-violet-400 text-violet-400", - text: "text-[#160b2f]", - label: "Graph", - fill: "#a78bfa", - stroke: "#ddd6fe", + legendLabel: "task", }, message: { - bar: "bg-cyan-400 border-cyan-300 text-[#06242a]", - marker: "bg-cyan-400 text-cyan-400", - text: "text-[#06242a]", - label: "Talk", - fill: "#22d3ee", - stroke: "#a5f3fc", + fill: "oklch(0.74 0.14 70)", + text: "white", + label: "Message", + legendLabel: "message", }, artifact: { - bar: "bg-emerald-400 border-emerald-300 text-[#052e1d]", - marker: "bg-emerald-400 text-emerald-400", - text: "text-[#052e1d]", + fill: "oklch(0.72 0.16 145)", + text: "white", label: "Artifact", - fill: "#34d399", - stroke: "#a7f3d0", + legendLabel: "artifact", }, evaluation: { - bar: "bg-rose-400 border-rose-300 text-[#3a0610]", - marker: "bg-rose-400 text-rose-400", - text: "text-[#3a0610]", + fill: "oklch(0.68 0.18 345)", + text: "white", label: "Evaluation", - fill: "#fb7185", - stroke: "#fecdd3", + legendLabel: "evaluation", }, context: { - bar: "bg-cyan-300 border-cyan-200 text-[#06242a]", - marker: "bg-cyan-300 text-cyan-300", - text: "text-[#06242a]", + fill: "oklch(0.66 0.12 230)", + text: "white", label: "Context", - fill: "#67e8f9", - stroke: "#cffafe", + legendLabel: "context/tool", }, sandbox: { - bar: "bg-slate-500 border-slate-400 text-white", - marker: "bg-slate-500 text-slate-500", - text: "text-white", + fill: "oklch(0.70 0.12 195)", + text: "white", label: "Sandbox", - fill: "#94a3b8", - stroke: "#cbd5e1", + legendLabel: "sandbox", }, }; @@ -68,6 +54,16 @@ export function activityKindLabel(kind: ActivityKind): string { return KIND_STYLES[kind].label; } +export function activityKindLegendLabel(kind: ActivityKind): string { + return KIND_STYLES[kind].legendLabel; +} + +export function activityKindColor(kind: ActivityKind): string { + return KIND_STYLES[kind].fill; +} + +export const ALL_ACTIVITY_KINDS = Object.keys(KIND_STYLES) as ActivityKind[]; + function testIdFor(activity: RunActivity): string { return `activity-bar-${activity.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`; } @@ -76,43 +72,69 @@ export function ActivityBar({ item, selected, highlighted, + current, + relation, onClick, + onHoverStart, + onHoverEnd, }: { item: ActivityStackItem; selected: boolean; highlighted: boolean; + current: boolean; + relation: "focused" | "related" | "dimmed" | "none"; onClick: (activity: RunActivity) => void; + onHoverStart: (activity: RunActivity) => void; + onHoverEnd: () => void; }) { const { activity, leftPct, widthPct } = item; const styles = KIND_STYLES[activity.kind]; const isMarker = activity.isInstant; + return ( - - - + {/* Kind legend */} +
+ + + Span + + + + Point event + + {STACK_ACTIVITY_KINDS.map((kind) => ( + + + {activityKindLegendLabel(kind)} + + ))}
- {mutations.length > 0 && ( - onSequenceChange(Number(event.target.value))} - className="mx-4 mt-2 h-1.5 w-[calc(100%-2rem)] cursor-pointer appearance-none rounded-full bg-white/10 accent-indigo-400" - aria-label="Run timeline sequence" - /> + {focusActivity && ( + )} -
- {(Object.keys(counts) as ActivityKind[]).map((kind) => ( - - {activityKindLabel(kind)} {counts[kind]} - - ))} -
+ {/* Stack content */} +
+
+
+
Trace spans
+ Band = semantic category. Sub-row = visual overlap. +
-
-
- Concurrent activity
- Bars stack only when they overlap +
+ {timeLabels.map((label, i) => ( + + {label} + {i === timeSlots - 2 && !isReplayLocked && · now} + + ))} +
-
- {Array.from({ length: layout.rowCount }).map((_, row) => ( -
- ))} - {layout.items.map((item) => ( -
- -
- ))} + +
+ {ACTIVITY_BAND_ORDER.map((band) => { + const bandLayout = layout.bands.find((entry) => entry.band === band); + if (!bandLayout) return null; + const bandItems = layout.items.filter((item) => item.activity.band === band); + const labels = BAND_LABELS[band]; + return ( +
+
+
+ {labels.title} +
+
{labels.note}
+
+
+ {Array.from({ length: bandLayout.rowCount }).map((_, row) => ( +
+ ))} + + {bandItems.map((item) => { + const relation = !focusActivity + ? "none" + : item.activity.id === focusActivity.id + ? "focused" + : relatedActivityIds.has(item.activity.id) + ? "related" + : "dimmed"; + return ( +
+ setHoveredActivityId(activity.id)} + onHoverEnd={() => setHoveredActivityId(null)} + /> +
+ ); + })} + + {/* Snapshot pin (indigo) */} + {hasMutations && isReplayLocked && snapshotLeftPct !== null && ( + <> +
+
+ SEQ {currentSequence} +
+ + )} + + {/* NOW cursor (green, live mode) */} + {!isReplayLocked && ( + <> +
+
+ + NOW +
+ + )} +
+
+ ); + })} +
+ + {/* Footer hints */} +
+ {layout.rowCount} trace rows across {layout.bands.length} semantic bands + Hover = lineage focus + Click = inspect graph snapshot
diff --git a/ergon-dashboard/src/features/activity/currentActivity.test.ts b/ergon-dashboard/src/features/activity/currentActivity.test.ts new file mode 100644 index 00000000..47b35657 --- /dev/null +++ b/ergon-dashboard/src/features/activity/currentActivity.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RunActivity } from "./types"; +import { resolveCurrentActivityId } from "./currentActivity"; + +function activity(id: string, startAt: string, sequence: number | null = null): RunActivity { + return { + id, + kind: "graph", + band: "graph", + label: id, + taskId: null, + sequence, + startAt, + endAt: null, + isInstant: true, + actor: null, + sourceKind: "graph.mutation", + metadata: {}, + lineage: {}, + debug: { source: "graph.mutation", payload: { id } }, + }; +} + +test("resolveCurrentActivityId chooses latest activity at or before cursor time", () => { + assert.equal( + resolveCurrentActivityId( + [ + activity("before", "2026-04-26T12:00:05.000Z"), + activity("current", "2026-04-26T12:00:08.000Z"), + activity("after", "2026-04-26T12:00:09.000Z"), + ], + "2026-04-26T12:00:08.500Z", + ), + "current", + ); +}); + +test("resolveCurrentActivityId breaks timestamp ties by highest graph sequence", () => { + assert.equal( + resolveCurrentActivityId( + [ + activity("older-seq", "2026-04-26T12:00:08.000Z", 10), + activity("newer-seq", "2026-04-26T12:00:08.000Z", 14), + ], + "2026-04-26T12:00:08.000Z", + ), + "newer-seq", + ); +}); + +test("resolveCurrentActivityId does not choose future graph sequence at same timestamp", () => { + assert.equal( + resolveCurrentActivityId( + [ + activity("current-seq", "2026-04-26T12:00:08.000Z", 10), + activity("future-seq", "2026-04-26T12:00:08.000Z", 14), + ], + "2026-04-26T12:00:08.000Z", + 10, + ), + "current-seq", + ); +}); + +test("resolveCurrentActivityId returns null before the first activity", () => { + assert.equal( + resolveCurrentActivityId( + [activity("after", "2026-04-26T12:00:09.000Z")], + "2026-04-26T12:00:08.000Z", + ), + null, + ); +}); diff --git a/ergon-dashboard/src/features/activity/currentActivity.ts b/ergon-dashboard/src/features/activity/currentActivity.ts new file mode 100644 index 00000000..6308e694 --- /dev/null +++ b/ergon-dashboard/src/features/activity/currentActivity.ts @@ -0,0 +1,45 @@ +import type { RunActivity } from "./types"; + +function parseTime(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; +} + +export function resolveCurrentActivityId( + activities: RunActivity[], + currentTimestamp: string | null, + currentSequence: number | null = null, +): string | null { + if (!currentTimestamp) return null; + const currentMs = Date.parse(currentTimestamp); + if (!Number.isFinite(currentMs)) return null; + + let selected: RunActivity | null = null; + let selectedMs = Number.NEGATIVE_INFINITY; + for (const activity of activities) { + const activityMs = parseTime(activity.startAt); + if (activityMs > currentMs) continue; + if ( + currentSequence !== null && + activity.sequence !== null && + activity.sequence > currentSequence + ) { + continue; + } + if ( + activityMs > selectedMs || + (activityMs === selectedMs && + (activity.sequence ?? Number.NEGATIVE_INFINITY) > + (selected?.sequence ?? Number.NEGATIVE_INFINITY)) || + (activityMs === selectedMs && + (activity.sequence ?? Number.NEGATIVE_INFINITY) === + (selected?.sequence ?? Number.NEGATIVE_INFINITY) && + (!selected || activity.id > selected.id)) + ) { + selected = activity; + selectedMs = activityMs; + } + } + + return selected?.id ?? null; +} diff --git a/ergon-dashboard/src/features/activity/goldenFixture.test.ts b/ergon-dashboard/src/features/activity/goldenFixture.test.ts index a1bf4515..14dbc631 100644 --- a/ergon-dashboard/src/features/activity/goldenFixture.test.ts +++ b/ergon-dashboard/src/features/activity/goldenFixture.test.ts @@ -55,5 +55,8 @@ test("golden concurrent fixture replays the whole graph at selected sequence and new Set(displayState.tasks.keys()), new Set(checkpoint.expectedTaskIds), ); - assert.equal(stack.maxConcurrency, checkpoint.expectedMaxConcurrency); + assert.ok(stack.maxConcurrency >= checkpoint.expectedMaxConcurrency); + assert.ok(activities.some((activity) => activity.kind === "context")); + assert.ok(activities.some((activity) => activity.kind === "artifact")); + assert.ok(activities.some((activity) => activity.kind === "evaluation")); }); diff --git a/ergon-dashboard/src/features/activity/snapshotSequence.test.ts b/ergon-dashboard/src/features/activity/snapshotSequence.test.ts new file mode 100644 index 00000000..0e61e03c --- /dev/null +++ b/ergon-dashboard/src/features/activity/snapshotSequence.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { GraphMutationDto } from "@/features/graph/contracts/graphMutations"; +import type { RunActivity } from "./types"; +import { resolveActivitySnapshotSequence } from "./snapshotSequence"; + +function activity(overrides: Partial = {}): RunActivity { + return { + id: "activity-1", + kind: "execution", + band: "work", + label: "Activity", + taskId: "task-1", + sequence: null, + startAt: "2026-04-26T12:00:10.000Z", + endAt: null, + isInstant: true, + actor: null, + sourceKind: "execution.span", + metadata: {}, + lineage: { taskId: "task-1", taskExecutionId: "activity-1" }, + ...overrides, + debug: overrides.debug ?? { source: "execution.span", payload: { id: "activity-1" } }, + }; +} + +function mutation(sequence: number, createdAt: string): GraphMutationDto { + return { + id: "00000000-0000-4000-8000-000000000001", + run_id: "00000000-0000-4000-8000-000000000002", + sequence, + mutation_type: "node.added", + target_type: "node", + target_id: "00000000-0000-4000-8000-000000000003", + actor: "system", + old_value: null, + new_value: {}, + reason: null, + created_at: createdAt, + }; +} + +test("uses explicit activity sequence when present", () => { + const result = resolveActivitySnapshotSequence( + activity({ sequence: 7, startAt: "not-a-date" }), + [mutation(1, "2026-04-26T12:00:00.000Z")], + ); + + assert.equal(result, 7); +}); + +test("uses nearest mutation at or before activity start time when sequence is absent", () => { + const result = resolveActivitySnapshotSequence( + activity({ startAt: "2026-04-26T12:00:10.000Z" }), + [ + mutation(1, "2026-04-26T12:00:00.000Z"), + mutation(2, "2026-04-26T12:00:05.000Z"), + mutation(3, "2026-04-26T12:00:15.000Z"), + ], + ); + + assert.equal(result, 2); +}); + +test("uses matching mutation timestamp and highest sequence for timestamp ties", () => { + const result = resolveActivitySnapshotSequence( + activity({ startAt: "2026-04-26T12:00:10.000Z" }), + [ + mutation(1, "2026-04-26T12:00:00.000Z"), + mutation(2, "2026-04-26T12:00:10.000Z"), + mutation(3, "2026-04-26T12:00:10.000Z"), + ], + ); + + assert.equal(result, 3); +}); + +test("uses nearest prior timestamp even when mutation timestamps are not monotonic", () => { + const result = resolveActivitySnapshotSequence( + activity({ startAt: "2026-04-26T12:00:10.000Z" }), + [ + mutation(1, "2026-04-26T12:00:00.000Z"), + mutation(2, "2026-04-26T12:00:15.000Z"), + mutation(3, "2026-04-26T12:00:05.000Z"), + ], + ); + + assert.equal(result, 3); +}); + +test("ignores invalid mutation timestamps while considering later valid candidates", () => { + const result = resolveActivitySnapshotSequence( + activity({ startAt: "2026-04-26T12:00:10.000Z" }), + [ + mutation(1, "2026-04-26T12:00:00.000Z"), + mutation(2, "2026-04-26T12:00:15.000Z"), + mutation(3, "not-a-date"), + mutation(4, "2026-04-26T12:00:05.000Z"), + ], + ); + + assert.equal(result, 4); +}); + +test("returns null when no mutation can represent activity time", () => { + const result = resolveActivitySnapshotSequence( + activity({ startAt: "2026-04-26T12:00:10.000Z" }), + [mutation(1, "2026-04-26T12:00:15.000Z")], + ); + + assert.equal(result, null); +}); + +test("ignores invalid mutation timestamps and returns null for invalid activity timestamps", () => { + assert.equal( + resolveActivitySnapshotSequence( + activity({ startAt: "not-a-date" }), + [mutation(1, "2026-04-26T12:00:00.000Z")], + ), + null, + ); + + assert.equal( + resolveActivitySnapshotSequence( + activity({ startAt: "2026-04-26T12:00:10.000Z" }), + [ + mutation(1, "not-a-date"), + mutation(2, "2026-04-26T12:00:05.000Z"), + ], + ), + 2, + ); +}); diff --git a/ergon-dashboard/src/features/activity/snapshotSequence.ts b/ergon-dashboard/src/features/activity/snapshotSequence.ts new file mode 100644 index 00000000..0e3a3c4d --- /dev/null +++ b/ergon-dashboard/src/features/activity/snapshotSequence.ts @@ -0,0 +1,28 @@ +import type { GraphMutationDto } from "@/features/graph/contracts/graphMutations"; +import type { RunActivity } from "./types"; + +export function resolveActivitySnapshotSequence( + activity: RunActivity, + mutations: GraphMutationDto[], +): number | null { + if (activity.sequence !== null) return activity.sequence; + + const activityMs = Date.parse(activity.startAt); + if (!Number.isFinite(activityMs)) return null; + + let selected: GraphMutationDto | null = null; + let selectedMs = Number.NEGATIVE_INFINITY; + for (const mutation of mutations) { + const mutationMs = Date.parse(mutation.created_at); + if (!Number.isFinite(mutationMs)) continue; + if (mutationMs > activityMs) continue; + if ( + mutationMs > selectedMs || + (mutationMs === selectedMs && (!selected || mutation.sequence > selected.sequence)) + ) { + selected = mutation; + selectedMs = mutationMs; + } + } + return selected?.sequence ?? null; +} diff --git a/ergon-dashboard/src/features/activity/stackLayout.test.ts b/ergon-dashboard/src/features/activity/stackLayout.test.ts index acc0dd98..b1dfaefb 100644 --- a/ergon-dashboard/src/features/activity/stackLayout.test.ts +++ b/ergon-dashboard/src/features/activity/stackLayout.test.ts @@ -13,6 +13,7 @@ function activity( return { id, kind: "execution", + band: "work", label: id, taskId: id, sequence: null, @@ -22,6 +23,27 @@ function activity( actor, sourceKind: "execution.span", metadata: {}, + lineage: { taskId: id, taskExecutionId: id }, + debug: { source: "execution.span", payload: { id } }, + }; +} + +function marker(id: string, startAt: string): RunActivity { + return { + id, + kind: "graph", + band: "graph", + label: id, + taskId: id, + sequence: null, + startAt, + endAt: null, + isInstant: true, + actor: null, + sourceKind: "graph.mutation", + metadata: {}, + lineage: { taskId: id }, + debug: { source: "graph.mutation", payload: { id } }, }; } @@ -64,3 +86,71 @@ test("stackActivities computes point-in-time concurrency instead of interval int assert.equal(layout.maxConcurrency, 2); }); + +test("stackActivities stacks instant markers when their visual footprints overlap", () => { + const layout = stackActivities([ + activity("span", "2026-04-26T12:00:00.000Z", "2026-04-26T12:00:30.000Z"), + marker("m1", "2026-04-26T12:00:05.000Z"), + marker("m2", "2026-04-26T12:00:05.050Z"), + marker("m3", "2026-04-26T12:00:10.000Z"), + ]); + const rowById = new Map(layout.items.map((item) => [item.activity.id, item.row])); + + assert.equal(layout.bands.find((band) => band.band === "work")?.rowCount, 1); + assert.equal(layout.bands.find((band) => band.band === "graph")?.rowCount, 2); + assert.equal(layout.rowCount, 3); + assert.equal(layout.maxConcurrency, 1); + assert.equal(rowById.get("m1"), 0); + assert.equal(rowById.get("m2"), 1); + assert.equal(rowById.get("m3"), 0); +}); + +test("stackActivities prevents marker and duration item covering inside non-work bands", () => { + const layout = stackActivities([ + { + ...activity("tool-span", "2026-04-26T12:00:05.000Z", "2026-04-26T12:00:10.000Z"), + kind: "context", + band: "tools", + }, + { + ...marker("tool-point", "2026-04-26T12:00:07.000Z"), + kind: "context", + band: "tools", + }, + { + ...marker("message-point", "2026-04-26T12:00:07.000Z"), + kind: "message", + band: "communication", + }, + { + ...marker("artifact-point", "2026-04-26T12:00:07.050Z"), + kind: "artifact", + band: "communication", + }, + ]); + const bandByName = new Map(layout.bands.map((band) => [band.band, band])); + const rowById = new Map(layout.items.map((item) => [item.activity.id, item.row])); + + assert.equal(bandByName.get("tools")?.rowCount, 2); + assert.equal(rowById.get("tool-span"), 0); + assert.equal(rowById.get("tool-point"), 1); + assert.equal(bandByName.get("communication")?.rowCount, 2); + assert.notEqual(rowById.get("message-point"), rowById.get("artifact-point")); +}); + +test("stackActivities packs rows independently inside semantic bands", () => { + const layout = stackActivities([ + { ...activity("work-a", "2026-04-26T12:00:00.000Z", "2026-04-26T12:00:20.000Z"), band: "work" }, + { ...activity("work-b", "2026-04-26T12:00:05.000Z", "2026-04-26T12:00:15.000Z"), band: "work" }, + { ...activity("tool-a", "2026-04-26T12:00:05.000Z", "2026-04-26T12:00:15.000Z"), kind: "context", band: "tools" }, + ]); + + const bandByName = new Map(layout.bands.map((band) => [band.band, band])); + const rowById = new Map(layout.items.map((item) => [item.activity.id, item.row])); + + assert.equal(bandByName.get("work")?.rowCount, 2); + assert.equal(bandByName.get("tools")?.rowCount, 1); + assert.equal(rowById.get("work-a"), 0); + assert.equal(rowById.get("work-b"), 1); + assert.equal(rowById.get("tool-a"), 0); +}); diff --git a/ergon-dashboard/src/features/activity/stackLayout.ts b/ergon-dashboard/src/features/activity/stackLayout.ts index e5e95967..67c3959b 100644 --- a/ergon-dashboard/src/features/activity/stackLayout.ts +++ b/ergon-dashboard/src/features/activity/stackLayout.ts @@ -1,4 +1,4 @@ -import type { ActivityStackLayout, RunActivity } from "./types"; +import type { ActivityBand, ActivityStackLayout, RunActivity } from "./types"; export interface StackActivityOptions { minMarkerWidthPct?: number; @@ -13,9 +13,18 @@ interface TimedActivity { } const DEFAULT_MARKER_DURATION_MS = 250; +const DEFAULT_MIN_MARKER_WIDTH_PCT = 1.6; +const ROW_GUTTER_PCT = 0.15; +export const ACTIVITY_BAND_ORDER: ActivityBand[] = [ + "work", + "graph", + "tools", + "communication", + "outputs", +]; -function firstFreeRow(rowEnds: number[], startMs: number): number { - const row = rowEnds.findIndex((endMs) => endMs <= startMs); +function firstFreeRow(rowEnds: number[], start: number): number { + const row = rowEnds.findIndex((end) => end <= start); return row === -1 ? rowEnds.length : row; } @@ -59,7 +68,7 @@ export function stackActivities( activities: RunActivity[], options: StackActivityOptions = {}, ): ActivityStackLayout { - const minMarkerWidthPct = options.minMarkerWidthPct ?? 0.35; + const minMarkerWidthPct = options.minMarkerWidthPct ?? DEFAULT_MIN_MARKER_WIDTH_PCT; const minSpanWidthPct = options.minSpanWidthPct ?? 0.75; const markerDurationMs = options.markerDurationMs ?? DEFAULT_MARKER_DURATION_MS; const timed = activities @@ -72,28 +81,54 @@ export function stackActivities( ); if (timed.length === 0) { - return { items: [], rowCount: 0, startMs: 0, endMs: 0, maxConcurrency: 0 }; + return { items: [], bands: [], rowCount: 0, startMs: 0, endMs: 0, maxConcurrency: 0 }; } const startMs = Math.min(...timed.map((item) => item.startMs)); const endMs = Math.max(...timed.map((item) => item.endMs)); const spanMs = Math.max(1, endMs - startMs); - const rowEnds: number[] = []; + const items = []; + const bands = []; - const items = timed.map(({ activity, startMs: itemStartMs, endMs: itemEndMs }) => { - const row = firstFreeRow(rowEnds, itemStartMs); - rowEnds[row] = itemEndMs; + for (const band of ACTIVITY_BAND_ORDER) { + const bandTimed = timed.filter((item) => item.activity.band === band); + if (bandTimed.length === 0) continue; - const leftPct = ((itemStartMs - startMs) / spanMs) * 100; - const rawWidthPct = ((itemEndMs - itemStartMs) / spanMs) * 100; - const widthPct = activity.isInstant - ? Math.max(minMarkerWidthPct, rawWidthPct) - : Math.max(minSpanWidthPct, rawWidthPct); + const rowEnds: number[] = []; + const bandItems = bandTimed.map(({ activity, startMs: itemStartMs, endMs: itemEndMs }) => { + const leftPct = ((itemStartMs - startMs) / spanMs) * 100; + const rawWidthPct = ((itemEndMs - itemStartMs) / spanMs) * 100; + const widthPct = Math.max( + activity.isInstant ? minMarkerWidthPct : minSpanWidthPct, + rawWidthPct, + ); + const row = firstFreeRow(rowEnds, leftPct); + rowEnds[row] = leftPct + widthPct + ROW_GUTTER_PCT; - return { activity, row, leftPct, widthPct }; - }); + return { activity, row, leftPct, widthPct }; + }); + + const rowCount = Math.max(1, rowEnds.length); + bands.push({ band, rowCount }); + items.push(...bandItems); + } const maxConcurrency = computeMaxSpanConcurrency(timed); + const rowCount = bands.reduce((sum, band) => sum + band.rowCount, 0); - return { items, rowCount: rowEnds.length, startMs, endMs, maxConcurrency }; + return { + items: items.sort( + (a, b) => + ACTIVITY_BAND_ORDER.indexOf(a.activity.band) - + ACTIVITY_BAND_ORDER.indexOf(b.activity.band) || + a.activity.startAt.localeCompare(b.activity.startAt) || + Number(a.activity.isInstant) - Number(b.activity.isInstant) || + a.activity.id.localeCompare(b.activity.id), + ), + bands, + rowCount, + startMs, + endMs, + maxConcurrency, + }; } diff --git a/ergon-dashboard/src/features/activity/types.ts b/ergon-dashboard/src/features/activity/types.ts index e4fa7336..9388bda4 100644 --- a/ergon-dashboard/src/features/activity/types.ts +++ b/ergon-dashboard/src/features/activity/types.ts @@ -9,9 +9,26 @@ export type ActivityKind = | "context" | "sandbox"; +export type ActivityBand = + | "work" + | "graph" + | "tools" + | "communication" + | "outputs"; + +export interface ActivityLineage { + taskId?: string | null; + taskExecutionId?: string | null; + sandboxId?: string | null; + agentId?: string | null; + workerBindingKey?: string | null; + threadId?: string | null; +} + export interface RunActivity { id: string; kind: ActivityKind; + band: ActivityBand; label: string; taskId: string | null; sequence: number | null; @@ -19,8 +36,19 @@ export interface RunActivity { endAt: string | null; isInstant: boolean; actor: string | null; - sourceKind: RunEventKind | "execution.span" | "sandbox.span" | "graph.mutation"; + sourceKind: + | RunEventKind + | "execution.span" + | "sandbox.span" + | "sandbox.command" + | "context.span" + | "graph.mutation"; metadata: Record; + lineage: ActivityLineage; + debug: { + source: string; + payload: unknown; + }; } export interface ActivityStackItem { @@ -30,8 +58,14 @@ export interface ActivityStackItem { widthPct: number; } +export interface ActivityBandLayout { + band: ActivityBand; + rowCount: number; +} + export interface ActivityStackLayout { items: ActivityStackItem[]; + bands: ActivityBandLayout[]; rowCount: number; startMs: number; endMs: number; diff --git a/ergon-dashboard/src/features/graph/components/ContainerNode.tsx b/ergon-dashboard/src/features/graph/components/ContainerNode.tsx index ed63a819..e0c6b5f4 100644 --- a/ergon-dashboard/src/features/graph/components/ContainerNode.tsx +++ b/ergon-dashboard/src/features/graph/components/ContainerNode.tsx @@ -3,10 +3,6 @@ import { memo } from "react"; import { Handle, Position } from "@xyflow/react"; import type { TaskState, TaskStatus } from "@/lib/types"; -import { TaskGraphStatusIcon } from "@/components/dag/TaskGraphStatusIcon"; -import { getLevelColor } from "@/features/graph/theme/levelColors"; -import { getTaskTimingPrimaryLine } from "@/features/graph/utils/taskTiming"; -import { tokensFor } from "@/lib/statusTokens"; interface ContainerNodeProps { task: TaskState; @@ -22,19 +18,19 @@ interface ContainerNodeProps { maxGraphDepth?: number; } -function ContainerNodeComponent({ - task, - isExpanded, - onToggleExpand, - onClick, - selected = false, - dimmed = false, - highlighted = false, - containerWidth, - containerHeight, - layoutDirection = "LR", - maxGraphDepth, -}: ContainerNodeProps) { +function ContainerNodeComponent(props: ContainerNodeProps) { + const { + task, + isExpanded, + onToggleExpand, + onClick, + selected = false, + dimmed = false, + highlighted = false, + containerWidth, + containerHeight, + layoutDirection = "LR", + } = props; const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(task.id); @@ -45,109 +41,96 @@ function ContainerNodeComponent({ onToggleExpand(task.id); }; - const tokens = tokensFor(task.status); - - const depthForPalette = Math.max(maxGraphDepth ?? task.level, task.level); - const levelHex = getLevelColor(task.level, depthForPalette); - const targetPos = layoutDirection === "LR" ? Position.Left : Position.Top; const sourcePos = layoutDirection === "LR" ? Position.Right : Position.Bottom; - const timingLine = getTaskTimingPrimaryLine(task); + + const isRunning = task.status === ("running" as TaskStatus); + const borderColor = isRunning ? "var(--status-running)" : "#cdd3dc"; return (
-
-
- -
- - {tokens.label} - -

- {task.name} -

- - {task.assignedWorkerName && ( - - - - - {task.assignedWorkerName} + {/* Header row */} +
+
+ + {task.name} - )} - - - {task.childIds.length} subtask{task.childIds.length !== 1 ? "s" : ""} - +
- {timingLine && ( +
- {timingLine} + {task.childIds.length} subtask{task.childIds.length !== 1 ? "s" : ""} - )} - + + + + +
- {task.status === ("running" as TaskStatus) && !dimmed && ( -
+ {isRunning && !dimmed && ( +
)}
); diff --git a/ergon-dashboard/src/features/graph/components/LeafNode.tsx b/ergon-dashboard/src/features/graph/components/LeafNode.tsx index 0e78409f..01d01e67 100644 --- a/ergon-dashboard/src/features/graph/components/LeafNode.tsx +++ b/ergon-dashboard/src/features/graph/components/LeafNode.tsx @@ -2,10 +2,6 @@ import { memo, useEffect, useState } from "react"; import type { TaskState, TaskStatus } from "@/lib/types"; -import { TaskGraphStatusIcon } from "@/components/dag/TaskGraphStatusIcon"; -import { getLevelColor } from "@/features/graph/theme/levelColors"; -import { getTaskTimingPrimaryLine } from "@/features/graph/utils/taskTiming"; -import { tokensFor } from "@/lib/statusTokens"; import { Handle, Position } from "@xyflow/react"; interface LeafNodeProps { @@ -19,42 +15,76 @@ interface LeafNodeProps { maxGraphDepth?: number; } -/** - * CornerBadge — slot-1-style corner status indicator. A solid circular badge - * ringed with white (or dark ring on dark mode) that overlaps the node's - * top-right corner; pulses on RUNNING. This replaces the floating - * `TaskGraphStatusIcon` which read as just another icon, not a status. - */ -function CornerBadge({ status }: { status: TaskStatus }) { - const tokens = tokensFor(status); +const STATUS_STYLES: Record< + string, + { bg: string; border: string; text: string } +> = { + completed: { + bg: "oklch(0.96 0.04 155)", + border: "oklch(0.85 0.10 155)", + text: "oklch(0.40 0.12 155)", + }, + running: { + bg: "oklch(0.97 0.04 80)", + border: "oklch(0.85 0.10 80)", + text: "oklch(0.42 0.12 65)", + }, + ready: { + bg: "oklch(0.97 0.03 240)", + border: "oklch(0.86 0.08 240)", + text: "oklch(0.40 0.12 240)", + }, + pending: { + bg: "#ffffff", + border: "#e2e6ec", + text: "#98a2b1", + }, + failed: { + bg: "oklch(0.97 0.04 22)", + border: "oklch(0.85 0.10 22)", + text: "oklch(0.40 0.16 22)", + }, +}; + +const FALLBACK_STYLE = STATUS_STYLES.pending; + +function getStatusStyle(status: string) { + return STATUS_STYLES[status] ?? FALLBACK_STYLE; +} + +function StatusDot({ status }: { status: string }) { + const style = getStatusStyle(status); + const isRunning = status === "running"; return ( -
- -
+ ); } -function LeafNodeComponent({ - task, - variant, - onClick, - selected = false, - dimmed = false, - highlighted = false, - layoutDirection = "LR", - maxGraphDepth, -}: LeafNodeProps) { +function LeafNodeComponent(props: LeafNodeProps) { + const { + task, + onClick, + selected = false, + dimmed = false, + highlighted = false, + layoutDirection = "LR", + } = props; const [isAnimating, setIsAnimating] = useState(false); const [prevStatus, setPrevStatus] = useState(task.status); const targetPos = layoutDirection === "LR" ? Position.Left : Position.Top; const sourcePos = layoutDirection === "LR" ? Position.Right : Position.Bottom; - const depthForPalette = Math.max(maxGraphDepth ?? task.level, task.level); - const levelHex = getLevelColor(task.level, depthForPalette); useEffect(() => { if (task.status !== prevStatus) { @@ -69,247 +99,72 @@ function LeafNodeComponent({ onClick?.(task.id); }; - const tokens = tokensFor(task.status); - const timingLine = getTaskTimingPrimaryLine(task); - - if (variant === "compact") { - const timingHint = timingLine ? `\n${timingLine}` : ""; - return ( -
- - - - {task.name} - - -
- ); - } - - if (variant === "standard") { - return ( -
- - - -
-
- - {task.status} - -
- -

- {task.name} -

+ const ss = getStatusStyle(task.status); - {task.assignedWorkerName && ( -
- - - - {task.assignedWorkerName} -
- )} - {timingLine && ( -

{timingLine}

- )} -
+ const statusLabel = + task.status === ("running" as TaskStatus) + ? `running${task.assignedWorkerName ? ` · ${task.assignedWorkerName}` : ""}` + : task.status; - - - {task.status === ("running" as TaskStatus) && !dimmed && ( -
- )} -
- ); - } - - // Full variant — matches original TaskNode rendering return (
- + + -
- {/* Header: Status + Level */} -
-
- - {task.status} - -
- - L{task.level} - -
- - {/* Task Name */} -

- {task.name} -

- - {/* Description */} - {task.description && task.description.length < 60 && ( -

- {task.description} -

- )} - - {/* Worker Assignment */} - {task.assignedWorkerName && ( -
- - - - {task.assignedWorkerName} -
- )} - {timingLine && ( -

{timingLine}

- )} - - {/* Leaf indicator */} - {task.isLeaf && ( -
- - Leaf task (no children) - - -
- )} +
+ {task.name} +
- {/* Children count indicator (collapsed container) */} - {!task.isLeaf && task.childIds.length > 0 && ( -
- - - - {task.childIds.length} subtasks -
- )} +
+ {statusLabel}
- - {/* Running pulse ring */} - {task.status === ("running" as TaskStatus) && !dimmed && ( -
- )}
); } diff --git a/ergon-dashboard/src/features/graph/components/MutationTimeline.tsx b/ergon-dashboard/src/features/graph/components/MutationTimeline.tsx index 13479c4d..261ef7f6 100644 --- a/ergon-dashboard/src/features/graph/components/MutationTimeline.tsx +++ b/ergon-dashboard/src/features/graph/components/MutationTimeline.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { GraphMutationDto } from "@/features/graph/contracts/graphMutations"; +import { formatClockTimeMs } from "@/lib/timeFormat"; interface MutationTimelineProps { mutations: GraphMutationDto[]; @@ -134,12 +135,7 @@ export function MutationTimeline({ } const formattedTime = currentMutation - ? new Date(currentMutation.created_at).toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - fractionalSecondDigits: 3, - }) + ? formatClockTimeMs(currentMutation.created_at) : "—"; const seqSpan = Math.max(1, maxSequence - minSequence); diff --git a/ergon-dashboard/src/features/graph/components/events/AssistantTextEvent.tsx b/ergon-dashboard/src/features/graph/components/events/AssistantTextEvent.tsx index 49a11245..da174719 100644 --- a/ergon-dashboard/src/features/graph/components/events/AssistantTextEvent.tsx +++ b/ergon-dashboard/src/features/graph/components/events/AssistantTextEvent.tsx @@ -1,4 +1,5 @@ import type { ContextEventPayload } from "@/lib/contracts/contextEvents"; +import { ContextEventCard, formatDuration } from "./ContextEventCard"; interface Props { payload: Extract; @@ -8,16 +9,13 @@ interface Props { export function AssistantTextEvent({ payload, startedAt, completedAt }: Props) { return ( -
- {startedAt && completedAt && ( - - {Math.round( - (new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 100, - ) / 10} - s - - )} +

{payload.text}

-
+ ); } diff --git a/ergon-dashboard/src/features/graph/components/events/ContextEventCard.tsx b/ergon-dashboard/src/features/graph/components/events/ContextEventCard.tsx new file mode 100644 index 00000000..e9573b07 --- /dev/null +++ b/ergon-dashboard/src/features/graph/components/events/ContextEventCard.tsx @@ -0,0 +1,125 @@ +import type { ReactNode } from "react"; + +type Tone = "amber" | "blue" | "green" | "gray" | "indigo" | "purple" | "red"; + +const TONE_STYLES: Record = { + amber: { + border: "border-amber-200/80", + bg: "bg-amber-50/80", + pill: "bg-amber-100 text-amber-800 ring-amber-200", + text: "text-amber-800", + }, + blue: { + border: "border-sky-200/80", + bg: "bg-sky-50/80", + pill: "bg-sky-100 text-sky-800 ring-sky-200", + text: "text-sky-800", + }, + green: { + border: "border-emerald-200/80", + bg: "bg-emerald-50/80", + pill: "bg-emerald-100 text-emerald-800 ring-emerald-200", + text: "text-emerald-800", + }, + gray: { + border: "border-[var(--line)]", + bg: "bg-[var(--paper)]", + pill: "bg-[var(--card)] text-[var(--muted)] ring-[var(--line)]", + text: "text-[var(--muted)]", + }, + indigo: { + border: "border-indigo-200/80", + bg: "bg-indigo-50/80", + pill: "bg-indigo-100 text-indigo-800 ring-indigo-200", + text: "text-indigo-800", + }, + purple: { + border: "border-purple-200/80", + bg: "bg-purple-50/80", + pill: "bg-purple-100 text-purple-800 ring-purple-200", + text: "text-purple-800", + }, + red: { + border: "border-red-200/80", + bg: "bg-red-50/80", + pill: "bg-red-100 text-red-800 ring-red-200", + text: "text-red-800", + }, +}; + +export function formatDuration(startedAt: string | null, completedAt: string | null): string | null { + if (!startedAt || !completedAt) return null; + const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime(); + if (!Number.isFinite(durationMs) || durationMs < 0) return null; + return `${Math.round(durationMs / 100) / 10}s`; +} + +export function ContextEventCard({ + tone, + title, + subtitle, + badge, + duration, + children, + payloadLabel, + payload, +}: { + tone: Tone; + title: string; + subtitle?: string | null; + badge?: string | null; + duration?: string | null; + children?: ReactNode; + payloadLabel?: string; + payload?: unknown; +}) { + const styles = TONE_STYLES[tone]; + + return ( +
+
+
+
+ + {title} + + {badge && ( + + {badge} + + )} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+ {duration && ( + + {duration} + + )} +
+ + {children &&
{children}
} + + {payloadLabel && ( +
+ + {payloadLabel} + +
+            {typeof payload === "string" ? payload : JSON.stringify(payload, null, 2)}
+          
+
+ )} +
+ ); +} diff --git a/ergon-dashboard/src/features/graph/components/events/SystemPromptEvent.tsx b/ergon-dashboard/src/features/graph/components/events/SystemPromptEvent.tsx index 7305eecb..026a8a30 100644 --- a/ergon-dashboard/src/features/graph/components/events/SystemPromptEvent.tsx +++ b/ergon-dashboard/src/features/graph/components/events/SystemPromptEvent.tsx @@ -1,4 +1,5 @@ import type { ContextEventPayload } from "@/lib/contracts/contextEvents"; +import { ContextEventCard } from "./ContextEventCard"; interface Props { payload: Extract; @@ -6,9 +7,11 @@ interface Props { export function SystemPromptEvent({ payload }: Props) { return ( -
- System Prompt -
{payload.text}
-
+ ); } diff --git a/ergon-dashboard/src/features/graph/components/events/ThinkingEvent.tsx b/ergon-dashboard/src/features/graph/components/events/ThinkingEvent.tsx index 77df44ed..0dbf342f 100644 --- a/ergon-dashboard/src/features/graph/components/events/ThinkingEvent.tsx +++ b/ergon-dashboard/src/features/graph/components/events/ThinkingEvent.tsx @@ -1,4 +1,5 @@ import type { ContextEventPayload } from "@/lib/contracts/contextEvents"; +import { ContextEventCard, formatDuration } from "./ContextEventCard"; interface Props { payload: Extract; @@ -8,19 +9,13 @@ interface Props { export function ThinkingEvent({ payload, startedAt, completedAt }: Props) { return ( -
- - Thinking - {startedAt && completedAt && ( - - {Math.round( - (new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 100, - ) / 10} - s - - )} - -

{payload.text}

-
+ +

{payload.text}

+
); } diff --git a/ergon-dashboard/src/features/graph/components/events/ToolCallEvent.tsx b/ergon-dashboard/src/features/graph/components/events/ToolCallEvent.tsx index f87c5167..f21e27c8 100644 --- a/ergon-dashboard/src/features/graph/components/events/ToolCallEvent.tsx +++ b/ergon-dashboard/src/features/graph/components/events/ToolCallEvent.tsx @@ -1,4 +1,5 @@ import type { ContextEventPayload } from "@/lib/contracts/contextEvents"; +import { ContextEventCard, formatDuration } from "./ContextEventCard"; interface Props { payload: Extract; @@ -8,19 +9,14 @@ interface Props { export function ToolCallEvent({ payload, startedAt, completedAt }: Props) { return ( -
- - {payload.tool_name} - {startedAt && completedAt && ( - - {Math.round( - (new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 100, - ) / 10} - s - - )} - -
{JSON.stringify(payload.args, null, 2)}
-
+ ); } diff --git a/ergon-dashboard/src/features/graph/components/events/ToolResultEvent.tsx b/ergon-dashboard/src/features/graph/components/events/ToolResultEvent.tsx index 867c38a8..97aa4159 100644 --- a/ergon-dashboard/src/features/graph/components/events/ToolResultEvent.tsx +++ b/ergon-dashboard/src/features/graph/components/events/ToolResultEvent.tsx @@ -1,4 +1,5 @@ import type { ContextEventPayload } from "@/lib/contracts/contextEvents"; +import { ContextEventCard } from "./ContextEventCard"; interface Props { payload: Extract; @@ -6,20 +7,13 @@ interface Props { export function ToolResultEvent({ payload }: Props) { return ( -
- - {payload.tool_name} result - {payload.is_error && ( - error - )} - -
{JSON.stringify(payload.result, null, 2)}
-
+ ); } diff --git a/ergon-dashboard/src/features/graph/components/events/UserMessageEvent.tsx b/ergon-dashboard/src/features/graph/components/events/UserMessageEvent.tsx index 8dcf314e..515498f2 100644 --- a/ergon-dashboard/src/features/graph/components/events/UserMessageEvent.tsx +++ b/ergon-dashboard/src/features/graph/components/events/UserMessageEvent.tsx @@ -1,4 +1,5 @@ import type { ContextEventPayload } from "@/lib/contracts/contextEvents"; +import { ContextEventCard } from "./ContextEventCard"; interface Props { payload: Extract; @@ -6,13 +7,12 @@ interface Props { export function UserMessageEvent({ payload }: Props) { return ( -
- {payload.from_worker_key && ( - - from {payload.from_worker_key} - - )} +

{payload.text}

-
+
); } diff --git a/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts b/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts index 0c51a088..a003c899 100644 --- a/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts +++ b/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts @@ -9,9 +9,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { MutationTypeSchema } from "./graphMutations"; -import { applyGraphMutation } from "../state/graphMutationReducer"; +import { applyGraphMutation, createReplayInitialState, replayToSequence } from "../state/graphMutationReducer"; import type { WorkflowRunState } from "@/lib/types"; +import { TaskStatus } from "@/lib/types"; import type { DashboardGraphMutationData } from "@/lib/contracts/events"; +import type { GraphMutationDto } from "./graphMutations"; function emptyState(): WorkflowRunState { return { @@ -126,3 +128,312 @@ for (const mutationType of ALL_MUTATION_TYPES) { test("ALL_MUTATION_TYPES matches MutationTypeSchema.options (no stale snapshot)", () => { assert.deepEqual(ALL_MUTATION_TYPES, MutationTypeSchema.options); }); + +test("replay base preserves snapshot hierarchy while dependency edges remain dependencies", () => { + const runState = emptyState(); + runState.tasks = new Map([ + [ + "11111111-1111-4111-8111-111111111111", + { + id: "11111111-1111-4111-8111-111111111111", + name: "root", + description: "root", + status: TaskStatus.RUNNING, + parentId: null, + childIds: [ + "22222222-2222-4222-8222-222222222222", + "33333333-3333-4333-8333-333333333333", + ], + dependsOnIds: [], + assignedWorkerId: null, + assignedWorkerName: "parent", + startedAt: "2026-04-26T12:00:00.000Z", + completedAt: null, + isLeaf: false, + level: 0, + }, + ], + [ + "22222222-2222-4222-8222-222222222222", + { + id: "22222222-2222-4222-8222-222222222222", + name: "dependency", + description: "dependency", + status: TaskStatus.COMPLETED, + parentId: "11111111-1111-4111-8111-111111111111", + childIds: [], + dependsOnIds: [], + assignedWorkerId: null, + assignedWorkerName: "worker-a", + startedAt: "2026-04-26T12:00:01.000Z", + completedAt: "2026-04-26T12:00:05.000Z", + isLeaf: true, + level: 1, + }, + ], + [ + "33333333-3333-4333-8333-333333333333", + { + id: "33333333-3333-4333-8333-333333333333", + name: "dependent", + description: "dependent", + status: TaskStatus.RUNNING, + parentId: "11111111-1111-4111-8111-111111111111", + childIds: [], + dependsOnIds: ["22222222-2222-4222-8222-222222222222"], + assignedWorkerId: "future-agent-id", + assignedWorkerName: "worker-b", + startedAt: "2026-04-26T12:00:06.000Z", + completedAt: null, + isLeaf: true, + level: 1, + }, + ], + ]); + + const mutations: GraphMutationDto[] = [ + graphNodeAdded(0, "11111111-1111-4111-8111-111111111111", "root"), + graphNodeAdded(1, "22222222-2222-4222-8222-222222222222", "dependency"), + graphNodeAdded(2, "33333333-3333-4333-8333-333333333333", "dependent"), + { + id: "44444444-4444-4444-8444-444444444444", + run_id: "00000000-0000-0000-0000-000000000000", + sequence: 3, + mutation_type: "edge.added", + target_type: "edge", + target_id: "44444444-4444-4444-8444-444444444444", + actor: "manager", + old_value: null, + new_value: { + source_node_id: "22222222-2222-4222-8222-222222222222", + target_node_id: "33333333-3333-4333-8333-333333333333", + status: "pending", + }, + reason: "manager_decision", + created_at: "2026-04-26T12:00:03.000Z", + }, + ]; + + const base = createReplayInitialState(runState, mutations, 3); + const replayed = mutations.reduce( + (state, mutation) => + applyGraphMutation(state, { ...mutation, timestamp: mutation.created_at }), + base, + ); + + const dependent = replayed.tasks.get("33333333-3333-4333-8333-333333333333"); + assert.equal(dependent?.parentId, "11111111-1111-4111-8111-111111111111"); + assert.deepEqual(dependent?.dependsOnIds, ["22222222-2222-4222-8222-222222222222"]); + assert.equal(dependent?.level, 1); +}); + +test("replay base does not leak future dependency edges or node field changes", () => { + const runState = emptyState(); + runState.tasks = new Map([ + [ + "11111111-1111-4111-8111-111111111111", + { + id: "11111111-1111-4111-8111-111111111111", + name: "root", + description: "root", + status: TaskStatus.RUNNING, + parentId: null, + childIds: [ + "22222222-2222-4222-8222-222222222222", + "33333333-3333-4333-8333-333333333333", + ], + dependsOnIds: [], + assignedWorkerId: null, + assignedWorkerName: "parent", + startedAt: "2026-04-26T12:00:00.000Z", + completedAt: null, + isLeaf: false, + level: 0, + }, + ], + [ + "22222222-2222-4222-8222-222222222222", + { + id: "22222222-2222-4222-8222-222222222222", + name: "source", + description: "source updated", + status: TaskStatus.COMPLETED, + parentId: "11111111-1111-4111-8111-111111111111", + childIds: [], + dependsOnIds: [], + assignedWorkerId: "future-agent-id", + assignedWorkerName: "future-worker", + startedAt: null, + completedAt: null, + isLeaf: true, + level: 1, + }, + ], + [ + "33333333-3333-4333-8333-333333333333", + { + id: "33333333-3333-4333-8333-333333333333", + name: "target", + description: "target", + status: TaskStatus.PENDING, + parentId: "11111111-1111-4111-8111-111111111111", + childIds: [], + dependsOnIds: ["22222222-2222-4222-8222-222222222222"], + assignedWorkerId: null, + assignedWorkerName: "worker-b", + startedAt: null, + completedAt: null, + isLeaf: true, + level: 1, + }, + ], + ]); + + const mutations: GraphMutationDto[] = [ + graphNodeAdded(0, "11111111-1111-4111-8111-111111111111", "root"), + graphNodeAdded(1, "22222222-2222-4222-8222-222222222222", "source"), + graphNodeAdded(2, "33333333-3333-4333-8333-333333333333", "target"), + { + id: "66666666-6666-4666-8666-666666666666", + run_id: "00000000-0000-0000-0000-000000000000", + sequence: 3, + mutation_type: "node.field_changed", + target_type: "node", + target_id: "22222222-2222-4222-8222-222222222222", + actor: "manager", + old_value: { description: "source" }, + new_value: { field: "description", value: "source updated" }, + reason: "update later", + created_at: "2026-04-26T12:00:03.000Z", + }, + { + id: "77777777-7777-4777-8777-777777777777", + run_id: "00000000-0000-0000-0000-000000000000", + sequence: 4, + mutation_type: "edge.added", + target_type: "edge", + target_id: "77777777-7777-4777-8777-777777777777", + actor: "manager", + old_value: null, + new_value: { + source_node_id: "22222222-2222-4222-8222-222222222222", + target_node_id: "33333333-3333-4333-8333-333333333333", + status: "pending", + }, + reason: "manager_decision", + created_at: "2026-04-26T12:00:04.000Z", + }, + ]; + + const replayed = replayToSequence( + mutations, + 2, + createReplayInitialState(runState, mutations, 2), + ); + + const source = replayed.tasks.get("22222222-2222-4222-8222-222222222222"); + const target = replayed.tasks.get("33333333-3333-4333-8333-333333333333"); + assert.equal(source?.description, "source"); + assert.equal(source?.assignedWorkerId, null); + assert.equal(source?.assignedWorkerName, "worker"); + assert.deepEqual(target?.dependsOnIds, []); +}); + +test("dependency edges between root-level tasks do not become containment", () => { + const runState = emptyState(); + runState.tasks = new Map([ + [ + "22222222-2222-4222-8222-222222222222", + { + id: "22222222-2222-4222-8222-222222222222", + name: "source", + description: "source", + status: TaskStatus.COMPLETED, + parentId: null, + childIds: [], + dependsOnIds: [], + assignedWorkerId: null, + assignedWorkerName: "worker-a", + startedAt: null, + completedAt: null, + isLeaf: true, + level: 0, + }, + ], + [ + "33333333-3333-4333-8333-333333333333", + { + id: "33333333-3333-4333-8333-333333333333", + name: "target", + description: "target", + status: TaskStatus.PENDING, + parentId: null, + childIds: [], + dependsOnIds: ["22222222-2222-4222-8222-222222222222"], + assignedWorkerId: null, + assignedWorkerName: "worker-b", + startedAt: null, + completedAt: null, + isLeaf: true, + level: 0, + }, + ], + ]); + const mutations: GraphMutationDto[] = [ + graphNodeAdded(0, "22222222-2222-4222-8222-222222222222", "source"), + graphNodeAdded(1, "33333333-3333-4333-8333-333333333333", "target"), + { + id: "88888888-8888-4888-8888-888888888888", + run_id: "00000000-0000-0000-0000-000000000000", + sequence: 2, + mutation_type: "edge.added", + target_type: "edge", + target_id: "88888888-8888-4888-8888-888888888888", + actor: "manager", + old_value: null, + new_value: { + source_node_id: "22222222-2222-4222-8222-222222222222", + target_node_id: "33333333-3333-4333-8333-333333333333", + status: "pending", + }, + reason: "manager_decision", + created_at: "2026-04-26T12:00:02.000Z", + }, + ]; + + const replayed = replayToSequence( + mutations, + 2, + createReplayInitialState(runState, mutations, 2), + ); + const target = replayed.tasks.get("33333333-3333-4333-8333-333333333333"); + assert.equal(target?.parentId, null); + assert.equal(target?.level, 0); + assert.deepEqual(target?.dependsOnIds, ["22222222-2222-4222-8222-222222222222"]); +}); + +function graphNodeAdded( + sequence: number, + targetId: string, + slug: string, +): GraphMutationDto { + return { + id: `55555555-5555-4555-8555-55555555555${sequence}`, + run_id: "00000000-0000-0000-0000-000000000000", + sequence, + mutation_type: "node.added", + target_type: "node", + target_id: targetId, + actor: "manager", + old_value: null, + new_value: { + task_slug: slug, + instance_key: "default", + description: slug, + status: "pending", + assigned_worker_slug: "worker", + }, + reason: "manager_decision", + created_at: `2026-04-26T12:00:0${sequence}.000Z`, + }; +} diff --git a/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts b/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts index bd638469..ad3c1153 100644 --- a/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts +++ b/ergon-dashboard/src/features/graph/layout/goldenLayout.test.ts @@ -4,9 +4,8 @@ import type { Node } from "@xyflow/react"; import fixture from "../../../../tests/fixtures/mas-runs/concurrent-mas-run.json"; import { parseGraphMutationDtoArray } from "@/features/graph/contracts/graphMutations"; -import { replayToSequence } from "@/features/graph/state/graphMutationReducer"; +import { createReplayInitialState, replayToSequence } from "@/features/graph/state/graphMutationReducer"; import { deserializeRunState } from "@/lib/runState"; -import type { WorkflowRunState } from "@/lib/types"; import { calculateExpandedContainers, computeHierarchicalLayout } from "./hierarchicalLayout"; import { NODE_VARIANTS, getNodeVariant } from "./layoutTypes"; @@ -19,21 +18,6 @@ interface Rect { height: number; } -function emptyRunStateFrom(runState: WorkflowRunState): WorkflowRunState { - return { - ...runState, - tasks: new Map(), - totalTasks: 0, - totalLeafTasks: 0, - completedTasks: 0, - runningTasks: 0, - failedTasks: 0, - edges: new Map(), - annotationsByTarget: new Map(), - unhandledMutations: [], - }; -} - function rectFor(node: Node): Rect { const task = (node.data as { task?: { level: number } }).task; const variant = getNodeVariant(task?.level ?? 1); @@ -77,7 +61,7 @@ test("golden layout renders the full recursive graph without overlapping sibling const displayState = replayToSequence( mutations, checkpoint.sequence, - emptyRunStateFrom(runState), + createReplayInitialState(runState, mutations, checkpoint.sequence), new Map(), ); const result = computeHierarchicalLayout( @@ -92,4 +76,10 @@ test("golden layout renders the full recursive graph without overlapping sibling assert.deepEqual(new Set(result.nodes.map((node) => node.id)), new Set(checkpoint.expectedTaskIds)); assert.deepEqual(overlappingSiblingPairs(result.nodes), []); + for (const taskId of checkpoint.expectedTaskIds) { + const expected = runState.tasks.get(taskId); + const actual = displayState.tasks.get(taskId); + assert.equal(actual?.parentId, expected?.parentId ?? null); + assert.equal(actual?.level, expected?.level); + } }); diff --git a/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts b/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts index 1a4c1d1a..0040afca 100644 --- a/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts +++ b/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts @@ -308,19 +308,26 @@ function applyEdgeAdded( const updatedTarget = { ...target }; const updatedSource = { ...source }; - if (updatedTarget.parentId === null) { + const sourceAlreadyContainsTarget = updatedSource.childIds.includes(value.target_node_id); + const isContainmentEdge = + updatedTarget.parentId === value.source_node_id || + (updatedTarget.parentId === null && + (ctx.reason === "parent-child" || sourceAlreadyContainsTarget)); + + if (isContainmentEdge) { updatedTarget.parentId = value.source_node_id; updatedTarget.level = updatedSource.level + 1; - updatedSource.childIds = [...updatedSource.childIds, value.target_node_id]; - if (updatedSource.isLeaf) { + updatedSource.childIds = sourceAlreadyContainsTarget + ? updatedSource.childIds + : [...updatedSource.childIds, value.target_node_id]; + if (updatedSource.isLeaf && !sourceAlreadyContainsTarget) { updatedSource.isLeaf = false; state.totalLeafTasks -= 1; } } else if (updatedTarget.parentId !== value.source_node_id) { - updatedTarget.dependsOnIds = [ - ...updatedTarget.dependsOnIds, - value.source_node_id, - ]; + updatedTarget.dependsOnIds = updatedTarget.dependsOnIds.includes(value.source_node_id) + ? updatedTarget.dependsOnIds + : [...updatedTarget.dependsOnIds, value.source_node_id]; } state.tasks.set(value.source_node_id, updatedSource); @@ -444,6 +451,106 @@ function recalculateMetrics(state: WorkflowRunState): void { const SNAPSHOT_INTERVAL = 50; +function nodeIdsAddedAtOrBefore( + mutations: GraphMutationDto[], + upToSequence: number, +): Set { + const ids = new Set(); + for (const mutation of mutations) { + if (mutation.sequence > upToSequence) break; + if (mutation.mutation_type === "node.added") { + ids.add(mutation.target_id); + } + } + return ids; +} + +function initialNodeValueById( + mutations: GraphMutationDto[], + upToSequence: number, +): Map { + const values = new Map(); + for (const mutation of mutations) { + if (mutation.sequence > upToSequence) break; + if (mutation.mutation_type !== "node.added") continue; + const value = NodeAddedValueSchema.parse(mutation.new_value); + values.set(mutation.target_id, value); + } + return values; +} + +function countStatus( + tasks: Map, + status: TaskStatus, +): number { + let count = 0; + for (const task of tasks.values()) { + if (task.status === status) count += 1; + } + return count; +} + +/** + * Build the initial state used for timeline replay from the persisted REST + * snapshot's structural metadata. + * + * The graph mutation WAL records dependency edges, but it does not encode the + * containment tree (`parentId`, `childIds`, `level`). Replaying from a blank + * task map would therefore mistake dependency edges for parent-child edges and + * produce a different layout from a refresh. This seeds only nodes that already + * existed at `upToSequence`, then lets status/annotation mutations replay on top. + */ +export function createReplayInitialState( + runState: WorkflowRunState, + mutations: GraphMutationDto[], + upToSequence: number, +): WorkflowRunState { + const includedNodeIds = nodeIdsAddedAtOrBefore(mutations, upToSequence); + const initialNodeValues = initialNodeValueById(mutations, upToSequence); + const tasks = new Map(); + + for (const nodeId of includedNodeIds) { + const task = runState.tasks.get(nodeId); + const initialValue = initialNodeValues.get(nodeId); + if (!task) continue; + + const parentId = + task.parentId && includedNodeIds.has(task.parentId) ? task.parentId : null; + const childIds = task.childIds.filter((childId) => includedNodeIds.has(childId)); + + tasks.set(nodeId, { + ...task, + name: initialValue?.task_slug ?? task.name, + description: initialValue?.description ?? task.description, + status: (initialValue?.status as TaskStatus | undefined) ?? task.status, + assignedWorkerId: null, + assignedWorkerName: initialValue?.assigned_worker_slug ?? null, + parentId, + childIds, + dependsOnIds: [], + startedAt: null, + completedAt: null, + isLeaf: childIds.length === 0, + level: parentId === null ? 0 : task.level, + history: [], + lastTrigger: null, + }); + } + + return { + ...runState, + tasks, + totalTasks: tasks.size, + totalLeafTasks: Array.from(tasks.values()).filter((task) => task.isLeaf).length, + completedTasks: countStatus(tasks, TaskStatus.COMPLETED), + runningTasks: countStatus(tasks, TaskStatus.RUNNING), + failedTasks: countStatus(tasks, TaskStatus.FAILED), + edges: new Map(), + annotationsByTarget: new Map(), + unhandledMutations: [], + }; +} + /** * Replay mutations up to a given sequence number from an initial state. * Used by the timeline scrubber for WAL playback. diff --git a/ergon-dashboard/src/generated/rest/contracts.ts b/ergon-dashboard/src/generated/rest/contracts.ts index fee60197..b641a5d6 100644 --- a/ergon-dashboard/src/generated/rest/contracts.ts +++ b/ergon-dashboard/src/generated/rest/contracts.ts @@ -119,6 +119,7 @@ const RunCommunicationThreadDto = z.object({ runId: z.string(), taskId: z.union([z.string(), z.null()]).optional(), topic: z.string(), + summary: z.union([z.string(), z.null()]).optional(), agentAId: z.string(), agentBId: z.string(), createdAt: z.string().datetime({ offset: true }), @@ -233,6 +234,8 @@ const CohortRunRowDto = z completed_at: z.union([z.string(), z.null()]).optional(), running_time_ms: z.union([z.number(), z.null()]).optional(), final_score: z.union([z.number(), z.null()]).optional(), + total_tasks: z.union([z.number().int(), z.null()]).optional(), + total_cost_usd: z.union([z.number(), z.null()]).optional(), error_message: z.union([z.string(), z.null()]).optional(), }) .passthrough(); diff --git a/ergon-dashboard/src/hooks/useBuildHealth.ts b/ergon-dashboard/src/hooks/useBuildHealth.ts new file mode 100644 index 00000000..20c6787a --- /dev/null +++ b/ergon-dashboard/src/hooks/useBuildHealth.ts @@ -0,0 +1,74 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; + +export type BuildHealthStatus = "unknown" | "healthy" | "degraded"; + +interface HealthResponse { + status: "healthy" | "degraded"; + checks: Record; + errors?: string[]; + build: { nodeEnv: string; timestamp: string | null; pid: number }; +} + +interface BuildHealth { + status: BuildHealthStatus; + errors: string[]; + lastChecked: number | null; + check: () => Promise; +} + +const POLL_INTERVAL_MS = 60_000; +const DEGRADED_RETRY_MS = 10_000; + +export function useBuildHealth(): BuildHealth { + const [status, setStatus] = useState("unknown"); + const [errors, setErrors] = useState([]); + const [lastChecked, setLastChecked] = useState(null); + const timerRef = useRef | null>(null); + + const check = useCallback(async () => { + try { + const res = await fetch("/api/health", { + cache: "no-store", + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})) as Partial; + setStatus("degraded"); + setErrors(body.errors ?? [`Health check returned ${res.status}`]); + } else { + const body = (await res.json()) as HealthResponse; + setStatus(body.status === "healthy" ? "healthy" : "degraded"); + setErrors(body.errors ?? []); + } + } catch (e) { + setStatus("degraded"); + setErrors([ + `Health check failed: ${e instanceof Error ? e.message : "network error"}. ` + + "The dev server may need a restart (docker compose restart dashboard).", + ]); + } + setLastChecked(Date.now()); + }, []); + + useEffect(() => { + check(); + + const schedule = () => { + const interval = status === "degraded" ? DEGRADED_RETRY_MS : POLL_INTERVAL_MS; + timerRef.current = setTimeout(async () => { + await check(); + schedule(); + }, interval); + }; + + schedule(); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [check, status]); + + return { status, errors, lastChecked, check }; +} diff --git a/ergon-dashboard/src/hooks/useRunState.socketHydration.test.ts b/ergon-dashboard/src/hooks/useRunState.socketHydration.test.ts new file mode 100644 index 00000000..d5643eef --- /dev/null +++ b/ergon-dashboard/src/hooks/useRunState.socketHydration.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldRequestSocketSnapshot } from "./useRunState"; + +test("does not request socket full-state snapshot when REST or SSR state is already hydrated", () => { + assert.equal(shouldRequestSocketSnapshot(true), false); +}); + +test("requests socket full-state snapshot when no REST or SSR state is available yet", () => { + assert.equal(shouldRequestSocketSnapshot(false), true); +}); diff --git a/ergon-dashboard/src/hooks/useRunState.ts b/ergon-dashboard/src/hooks/useRunState.ts index f51fcd26..0c217ecd 100644 --- a/ergon-dashboard/src/hooks/useRunState.ts +++ b/ergon-dashboard/src/hooks/useRunState.ts @@ -103,6 +103,10 @@ function normalizeSandboxCommandState(command: RunSandboxCommand): SandboxComman }; } +export function shouldRequestSocketSnapshot(hasHydratedRunState: boolean): boolean { + return !hasHydratedRunState; +} + export function useRunState( runId: string, initialRunState: SerializedWorkflowRunState | null = null, @@ -501,17 +505,21 @@ export function useRunState( setIsSubscribed(true); setIsLoading((prev) => (hasRunStateRef.current ? false : prev)); - // Request full run state from server - console.log("[useRunState] Requesting full state for run", runId, "socket.connected:", socket.connected); - socket.emit("request:run", runId); - - // Set up a retry in case the first request is lost - retryTimeout = setTimeout(() => { - if (socket.connected) { - console.log("[useRunState] Retrying request:run for", runId); - socket.emit("request:run", runId); - } - }, 1000); + if (shouldRequestSocketSnapshot(hasRunStateRef.current)) { + // Request full run state only when REST/SSR did not hydrate us. + console.log("[useRunState] Requesting full state for run", runId, "socket.connected:", socket.connected); + socket.emit("request:run", runId); + + // Set up a retry in case the first request is lost + retryTimeout = setTimeout(() => { + if (socket.connected && shouldRequestSocketSnapshot(hasRunStateRef.current)) { + console.log("[useRunState] Retrying request:run for", runId); + socket.emit("request:run", runId); + } + }, 1000); + } else { + console.log("[useRunState] Skipping full socket state request; REST/SSR snapshot is already loaded", runId); + } } // Set up event listeners diff --git a/ergon-dashboard/src/lib/contracts/rest.ts b/ergon-dashboard/src/lib/contracts/rest.ts index 4b43f43a..cc596656 100644 --- a/ergon-dashboard/src/lib/contracts/rest.ts +++ b/ergon-dashboard/src/lib/contracts/rest.ts @@ -105,12 +105,23 @@ export interface CohortSummary } export interface CohortRunRow - extends Omit { + extends Omit< + RawCohortRunRow, + | "completed_at" + | "error_message" + | "final_score" + | "running_time_ms" + | "started_at" + | "total_cost_usd" + | "total_tasks" + > { completed_at: string | null; error_message: string | null; final_score: number | null; running_time_ms: number | null; started_at: string | null; + total_cost_usd: number | null; + total_tasks: number | null; } export interface CohortDetail { @@ -323,6 +334,7 @@ function normalizeRunCommunicationThread(thread: RawRunCommunicationThread): Run ...thread, messages: (thread.messages ?? []).map(normalizeRunCommunicationMessage), taskId: thread.taskId ?? null, + summary: thread.summary ?? null, }; } @@ -355,6 +367,8 @@ export function parseCohortDetail(input: unknown): CohortDetail { final_score: run.final_score ?? null, running_time_ms: run.running_time_ms ?? null, started_at: run.started_at ?? null, + total_cost_usd: run.total_cost_usd ?? null, + total_tasks: run.total_tasks ?? null, })), }; } diff --git a/ergon-dashboard/src/lib/timeFormat.test.ts b/ergon-dashboard/src/lib/timeFormat.test.ts new file mode 100644 index 00000000..47f2534a --- /dev/null +++ b/ergon-dashboard/src/lib/timeFormat.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatClockTime, formatClockTimeMs, formatClockTimeSeconds } from "./timeFormat"; + +test("formatClockTime is stable for UTC timestamps regardless of runtime local timezone", () => { + assert.equal(formatClockTime("2026-04-26T10:24:15.000Z"), "10:24"); +}); + +test("formatClockTime returns dash for invalid timestamps", () => { + assert.equal(formatClockTime("not-a-date"), "—"); + assert.equal(formatClockTime(Number.NaN), "—"); +}); + +test("formatClockTimeMs includes seconds and milliseconds with stable timezone", () => { + assert.equal(formatClockTimeMs("2026-04-26T10:24:15.123Z"), "10:24:15.123"); +}); + +test("formatClockTimeSeconds includes seconds with stable timezone", () => { + assert.equal(formatClockTimeSeconds("2026-04-26T10:24:15.123Z"), "10:24:15"); +}); diff --git a/ergon-dashboard/src/lib/timeFormat.ts b/ergon-dashboard/src/lib/timeFormat.ts new file mode 100644 index 00000000..f314cf2f --- /dev/null +++ b/ergon-dashboard/src/lib/timeFormat.ts @@ -0,0 +1,42 @@ +export function formatClockTime(value: string | number | Date): string { + return formatDateTime(value, { + hour: "2-digit", + minute: "2-digit", + }); +} + +export function formatClockTimeMs(value: string | number | Date): string { + return formatDateTime(value, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }); +} + +export function formatClockTimeSeconds(value: string | number | Date): string { + return formatDateTime(value, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +export function formatDate(value: string | number | Date): string { + return formatDateTime(value, { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} + +function formatDateTime(value: string | number | Date, options: Intl.DateTimeFormatOptions): string { + const date = value instanceof Date ? value : new Date(value); + if (!Number.isFinite(date.getTime())) return "—"; + + return new Intl.DateTimeFormat("en-GB", { + ...options, + hour12: false, + timeZone: "UTC", + }).format(date); +} diff --git a/ergon-dashboard/tailwind.config.ts b/ergon-dashboard/tailwind.config.ts index 987c895c..3dc93ca3 100644 --- a/ergon-dashboard/tailwind.config.ts +++ b/ergon-dashboard/tailwind.config.ts @@ -5,6 +5,7 @@ const config: Config = { content: [ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/features/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { @@ -12,6 +13,32 @@ const config: Config = { colors: { background: "var(--background)", foreground: "var(--foreground)", + paper: "var(--paper)", + "paper-2": "var(--paper-2)", + "paper-3": "var(--paper-3)", + card: "var(--card)", + ink: "var(--ink)", + "ink-2": "var(--ink-2)", + muted: "var(--muted)", + faint: "var(--faint)", + line: "var(--line)", + "line-strong": "var(--line-strong)", + accent: "var(--accent)", + "accent-soft": "var(--accent-soft)", + "accent-ink": "var(--accent-ink)", + }, + borderRadius: { + card: "var(--radius)", + "card-sm": "var(--radius-sm)", + }, + boxShadow: { + card: "var(--shadow-sm)", + "card-md": "var(--shadow)", + pop: "var(--shadow-pop)", + }, + fontFamily: { + sans: ["var(--font)"], + mono: ["var(--mono)"], }, }, }, diff --git a/ergon-dashboard/tests/contracts/contracts.test.ts b/ergon-dashboard/tests/contracts/contracts.test.ts index 4bb0f770..cfd735da 100644 --- a/ergon-dashboard/tests/contracts/contracts.test.ts +++ b/ergon-dashboard/tests/contracts/contracts.test.ts @@ -100,7 +100,9 @@ test("cohort detail parser accepts harness payload", () => { const parsed = parseCohortDetail(cohortDetail); assert.equal(parsed.summary.cohort_id, FIXTURE_IDS.cohortId); - assert.equal((parsed.runs ?? []).length, 1); + assert.equal((parsed.runs ?? []).length, 3); + assert.equal(parsed.runs[0]?.total_tasks, 10); + assert.equal(parsed.runs[0]?.total_cost_usd, 0.12); }); test("workflow started event parser validates recursive task trees", () => { @@ -173,6 +175,7 @@ test("dashboard nested DTO event parser accepts backend snake-case payloads", () run_id: thread.runId, task_id: thread.taskId, topic: thread.topic, + summary: "Leaf workers report completion artifacts and probe exit status.", agent_a_id: thread.agentAId, agent_b_id: thread.agentBId, created_at: thread.createdAt, @@ -193,6 +196,11 @@ test("dashboard nested DTO event parser accepts backend snake-case payloads", () }, }); + assert.equal( + parsedThread.thread.summary, + "Leaf workers report completion artifacts and probe exit status.", + ); + const parsedEvaluation = parseDashboardTaskEvaluationUpdatedData({ run_id: FIXTURE_IDS.runId, task_id: FIXTURE_IDS.solveTaskId, diff --git a/ergon-dashboard/tests/e2e/_shared/smoke.ts b/ergon-dashboard/tests/e2e/_shared/smoke.ts index 8e048809..7f98c562 100644 --- a/ergon-dashboard/tests/e2e/_shared/smoke.ts +++ b/ergon-dashboard/tests/e2e/_shared/smoke.ts @@ -111,14 +111,19 @@ async function openWorkspaceForGraphTask(page: Page, taskId: string): Promise { + await expect(page.getByTestId("activity-play-toggle")).toHaveCount(0); + await expect(page.getByTestId("activity-speed-control")).toHaveCount(0); + await expect(page.getByTestId("activity-step-back")).toHaveCount(0); + await expect(page.getByTestId("activity-step-forward")).toHaveCount(0); +} + async function assertRunWorkspace( page: Page, state: BackendRunState, runId: string, ): Promise { await expect(page.getByTestId("run-header")).toBeVisible(); - await expect(page.getByTestId("run-status-bar")).toBeVisible(); - await expect(page.getByTestId("run-status-count-completed")).toBeVisible(); await expect(page.getByTestId("graph-canvas")).toBeVisible(); await expect(page.getByTestId("activity-stack-region")).toBeVisible(); await expect(page.locator('[data-testid^="activity-bar-"]').first()).toBeVisible(); @@ -129,13 +134,28 @@ async function assertRunWorkspace( await openWorkspaceForGraphTask(page, selected.id); await expect(page.getByTestId("workspace-region")).toBeVisible(); await expect(page.getByTestId("workspace-header")).toContainText(selected.task_slug); + await expect(page.getByTestId("workspace-tab-overview")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-actions")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-communication")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-outputs")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-transitions")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-evaluation")).toBeVisible(); + + await page.getByTestId("workspace-tab-actions").click(); await expect(page.getByTestId("workspace-actions")).toBeVisible(); - await expect(page.getByTestId("workspace-outputs")).toBeVisible(); await expect(page.getByTestId("workspace-executions")).toBeVisible(); await expect(page.getByTestId("workspace-sandbox")).toBeVisible(); + + await page.getByTestId("workspace-tab-outputs").click(); + await expect(page.getByTestId("workspace-outputs")).toBeVisible(); + + await page.getByTestId("workspace-tab-communication").click(); await expect(page.getByTestId("workspace-communication")).toBeVisible(); + + await page.getByTestId("workspace-tab-transitions").click(); await expect(page.getByTestId("workspace-transitions")).toBeVisible(); + await page.getByTestId("workspace-tab-evaluation").click(); if (evaluatedTaskIds.has(selected.id)) { await expect(page.getByTestId("workspace-evaluation")).toContainText("Total score"); } else { @@ -150,9 +170,10 @@ async function assertRunWorkspace( await expect(page.locator('[data-testid^="event-row-"]').first()).toBeVisible(); if (state.mutation_count > 0) { - await page.getByTestId("mode-timeline").click(); + await page.locator('[data-testid^="activity-bar-"]').first().click(); await expect(page.getByTestId("timeline-region")).toBeVisible(); await expect(page.getByTestId("activity-current-sequence")).toContainText(/seq/i); + await expectNoTimelinePlaybackControls(page); } } @@ -224,10 +245,9 @@ export function defineSmokeSpec(cfg: SmokeSpecConfig): void { return; } - // sad-path run assertions (researchrubrics-only today). A failed leaf - // returns score-zero output so persistence still runs. - expect(state.status).toBe("completed"); - expect(state.resource_count).toBeGreaterThanOrEqual(17); + // Canonical sad path: l_2 fails, l_3 blocks, independent leaves complete. + expect(state.status).toBe("failed"); + expect(state.resource_count).toBeGreaterThanOrEqual(15); expect(state.executions.length).toBe(state.execution_count); expect(state.mutations.length).toBe(state.mutation_count); expect(state.thread_count).toBeGreaterThan(0); @@ -235,11 +255,11 @@ export function defineSmokeSpec(cfg: SmokeSpecConfig): void { const statusBySlug = new Map( state.graph_nodes.filter((n) => n.level > 0).map((n) => [n.task_slug, n.status]), ); - for (const slug of EXPECTED_SUBTASK_SLUGS) { + for (const slug of EXPECTED_SUBTASK_SLUGS.filter((s) => !["l_2", "l_3"].includes(s))) { expect(statusBySlug.get(slug)).toBe("completed"); } - const failedEval = state.evaluations.some((e) => e.score === 0.0); - expect(failedEval).toBe(true); + expect(statusBySlug.get("l_2")).toBe("failed"); + expect(statusBySlug.get("l_3")).toBe("blocked"); const cohortId = await client.getCohortId(cohortKey); await page.goto(`/cohorts/${cohortId}/runs/${run_id}`); diff --git a/ergon-dashboard/tests/e2e/activity-stack.spec.ts b/ergon-dashboard/tests/e2e/activity-stack.spec.ts index 39ee9184..24fcfe2b 100644 --- a/ergon-dashboard/tests/e2e/activity-stack.spec.ts +++ b/ergon-dashboard/tests/e2e/activity-stack.spec.ts @@ -6,7 +6,7 @@ import { CONCURRENT_MAS_FIXTURE_IDS, createConcurrentMasDashboardSeed, } from "../helpers/dashboardFixtures"; -import { resetHarness, seedHarness } from "../helpers/harnessClient"; +import { acquireHarnessLock, resetHarness, seedHarness } from "../helpers/harnessClient"; interface Box { x: number; @@ -15,9 +15,25 @@ interface Box { height: number; } +test.describe.configure({ mode: "serial" }); + +let releaseHarnessLock: (() => Promise) | null = null; + test.beforeEach(async ({ request }) => { - await resetHarness(request); - await seedHarness(request, createConcurrentMasDashboardSeed()); + releaseHarnessLock = await acquireHarnessLock(); + try { + await resetHarness(request); + await seedHarness(request, createConcurrentMasDashboardSeed()); + } catch (error) { + await releaseHarnessLock(); + releaseHarnessLock = null; + throw error; + } +}); + +test.afterEach(async () => { + await releaseHarnessLock?.(); + releaseHarnessLock = null; }); function boxesOverlap(a: Box, b: Box, tolerancePx = 2): boolean { @@ -45,6 +61,35 @@ async function overlappingPairsFor(page: Page, selector: string): Promise<[numbe return pairs; } +async function activityGeometry(page: Page): Promise> { + return page.locator('[data-activity-id]').evaluateAll((elements) => { + return Object.fromEntries( + elements.map((element) => { + return [ + element.getAttribute("data-activity-id") ?? "", + { + x: Number(element.getAttribute("data-left-pct")), + y: Number(element.getAttribute("data-row")), + width: Number(element.getAttribute("data-width-pct")), + height: 1, + }, + ]; + }), + ); + }); +} + +function expectGeometryStable(before: Record, after: Record) { + for (const [id, box] of Object.entries(before)) { + const next = after[id]; + expect(next, `${id} still exists after replay selection`).toBeTruthy(); + expect(Math.round(next.x * 1000), `${id} left pct`).toBe(Math.round(box.x * 1000)); + expect(Math.round(next.y), `${id} y`).toBe(Math.round(box.y)); + expect(Math.round(next.width * 1000), `${id} width pct`).toBe(Math.round(box.width * 1000)); + expect(Math.round(next.height), `${id} height`).toBe(Math.round(box.height)); + } +} + async function dumpScreenshots(page: Page) { if (process.env.VISUAL_DEBUGGER_SCREENSHOTS !== "1") return; const outDir = path.join(process.cwd(), "tmp", "visual-debugger"); @@ -67,6 +112,13 @@ async function dumpGraphScreenshot(page: Page) { }); } +async function expectNoTimelinePlaybackControls(page: Page) { + await expect(page.getByTestId("activity-play-toggle")).toHaveCount(0); + await expect(page.getByTestId("activity-speed-control")).toHaveCount(0); + await expect(page.getByTestId("activity-step-back")).toHaveCount(0); + await expect(page.getByTestId("activity-step-forward")).toHaveCount(0); +} + test("visual debugger renders graph, activity stack, and time-aware workspace", async ({ page }) => { await page.goto( `/cohorts/${CONCURRENT_MAS_FIXTURE_IDS.cohortId}/runs/${CONCURRENT_MAS_FIXTURE_IDS.runId}`, @@ -75,7 +127,22 @@ test("visual debugger renders graph, activity stack, and time-aware workspace", await expect(page.getByTestId("run-header")).toBeVisible(); await expect(page.getByTestId("graph-canvas")).toBeVisible(); await expect(page.getByTestId("activity-stack-region")).toBeVisible(); + await expect(page.getByTestId("activity-kind-legend")).toContainText("Span"); + await expect(page.getByTestId("activity-kind-legend")).toContainText("Point event"); + await expect(page.getByTestId("activity-band-work")).toBeVisible(); + await expect(page.getByTestId("activity-band-graph")).toBeVisible(); + await expect(page.getByTestId("activity-band-tools")).toBeVisible(); + await expect(page.getByTestId("activity-band-communication")).toBeVisible(); + await expect(page.getByTestId("activity-band-outputs")).toBeVisible(); + await expectNoTimelinePlaybackControls(page); expect(await page.getByTestId("activity-stack-row").count()).toBeGreaterThan(1); + await expect + .poll( + async () => + (await overlappingPairsFor(page, "[data-activity-id]")).length, + { timeout: 5000 }, + ) + .toBe(0); await expect .poll( async () => @@ -90,19 +157,36 @@ test("visual debugger renders graph, activity stack, and time-aware workspace", .toBe(0); await dumpGraphScreenshot(page); - await page.getByTestId("mode-timeline").click(); - await expect(page.getByTestId("activity-current-sequence")).toContainText("seq 14"); - await expect(page.getByText("Graph 18")).toBeVisible(); - - const firstExecution = page - .locator('[data-testid^="activity-bar-"][data-kind="execution"]') + const graphActivity = page + .locator('[data-activity-id^="graph:"]:not([data-task-id=""])') .first(); - await expect(firstExecution).toBeVisible(); - await firstExecution.click(); + await expect(graphActivity).toBeVisible(); + const beforeGeometry = await activityGeometry(page); + await graphActivity.hover(); + await expect(page.getByTestId("activity-debug-preview")).toBeVisible(); + await expect(page.getByTestId("activity-debug-preview")).toContainText("Lineage"); + await expect(page.getByTestId("activity-debug-preview")).toContainText("graph.mutation"); + expect(await page.locator('[data-relation="dimmed"]').count()).toBeGreaterThan(0); + await graphActivity.click(); + expectGeometryStable(beforeGeometry, await activityGeometry(page)); + await expect(page.locator('[data-current="true"]')).toHaveCount(1); + await expect(graphActivity).toHaveAttribute("data-current", "true"); await expect(page.getByTestId("workspace-region")).toBeVisible(); await expect(page.getByTestId("workspace-header")).toBeVisible(); + await expect(page.getByTestId("workspace-activity-detail")).toBeVisible(); + await expect(page.getByTestId("workspace-activity-detail")).toContainText("Graph mutation"); + await expect(page.getByTestId("workspace-activity-detail")).toContainText("payload"); + await expect(page.getByTestId("workspace-activity-detail")).toContainText("graph.mutation"); await expect(page.getByTestId("workspace-timeline-badge")).toContainText("seq"); + await expectNoTimelinePlaybackControls(page); + + await page.keyboard.press("Escape"); + const toolActivity = page.locator('[data-activity-id^="context:"]').first(); + await expect(toolActivity).toBeVisible(); + await toolActivity.click(); + await expect(page.getByTestId("workspace-timeline-badge")).toContainText(/seq [1-9]/); + await expect(page.getByTestId("snapshot-pin").first()).toBeVisible(); await dumpScreenshots(page); }); diff --git a/ergon-dashboard/tests/e2e/cohort.snapshot.spec.ts b/ergon-dashboard/tests/e2e/cohort.snapshot.spec.ts index 74b3abaf..e8ba4d3c 100644 --- a/ergon-dashboard/tests/e2e/cohort.snapshot.spec.ts +++ b/ergon-dashboard/tests/e2e/cohort.snapshot.spec.ts @@ -1,28 +1,56 @@ import { expect, test } from "@playwright/test"; import { createDashboardSeed, FIXTURE_IDS } from "../helpers/dashboardFixtures"; -import { resetHarness, seedHarness } from "../helpers/harnessClient"; +import { acquireHarnessLock, resetHarness, seedHarness } from "../helpers/harnessClient"; + +test.describe.configure({ mode: "serial" }); + +let releaseHarnessLock: (() => Promise) | null = null; test.beforeEach(async ({ request }) => { - await resetHarness(request); - await seedHarness(request, createDashboardSeed()); + releaseHarnessLock = await acquireHarnessLock(); + try { + await resetHarness(request); + await seedHarness(request, createDashboardSeed()); + } catch (error) { + await releaseHarnessLock(); + releaseHarnessLock = null; + throw error; + } +}); + +test.afterEach(async () => { + await releaseHarnessLock?.(); + releaseHarnessLock = null; }); test("cohort index renders cohort-first snapshot truth", async ({ page }) => { await page.goto("/"); - await expect(page.getByTestId("cohort-index-header")).toContainText("Experiment Cohorts"); + await expect(page.getByTestId("cohort-index-header")).toContainText("Cohorts"); await expect(page.getByTestId(`cohort-row-${FIXTURE_IDS.cohortId}`)).toContainText( "minif2f-react-worker-gpt5v3", ); - await expect(page.getByTestId(`cohort-row-${FIXTURE_IDS.cohortId}`)).toContainText("Runs"); + await expect(page.getByTestId("cohort-index-list")).toContainText("Runs"); }); test("cohort detail renders summary and run list", async ({ page }) => { await page.goto(`/cohorts/${FIXTURE_IDS.cohortId}`); await expect(page.getByTestId("cohort-header")).toContainText("minif2f-react-worker-gpt5v3"); - await expect(page.getByTestId("cohort-summary-cards")).toContainText("Total runs"); + await expect(page.getByTestId("cohort-summary-cards")).toContainText("Runs · pass / fail"); + await expect(page.getByTestId("cohort-summary-cards")).toContainText("3 of 3 runs"); + await expect(page.getByTestId("cohort-summary-cards")).toContainText("Avg tasks"); + await expect(page.getByTestId("cohort-summary-cards")).toContainText("10.0"); + await expect(page.getByRole("button", { name: "Compare" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Re-run failed" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Open in training" })).toHaveCount(0); + await expect(page.getByTestId("cohort-run-distribution")).toBeVisible(); + await expect(page.getByTestId("cohort-run-distribution")).toContainText("Score distribution"); + await expect(page.getByTestId("cohort-distribution-point")).toHaveCount(3); + await page.getByTestId("cohort-distribution-metric-runtime").click(); + await expect(page.getByTestId("cohort-run-distribution")).toContainText("Runtime distribution"); + await expect(page.getByTestId("cohort-distribution-point")).toHaveCount(3); const runRow = page.getByTestId(`cohort-run-row-${FIXTURE_IDS.runId}`); await expect(runRow).toContainText("minif2f-react-worker-gpt5v3"); await expect(runRow).toContainText("Started"); diff --git a/ergon-dashboard/tests/e2e/health.spec.ts b/ergon-dashboard/tests/e2e/health.spec.ts new file mode 100644 index 00000000..e774e624 --- /dev/null +++ b/ergon-dashboard/tests/e2e/health.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "@playwright/test"; + +/** + * E2E tests for the /api/health endpoint. + * + * Validates that: + * - The health endpoint is reachable and returns structured JSON + * - SSR imports are exercised (catches stale .next cache) + * - The response shape matches the expected schema + */ + +const BASE = process.env.BASE_URL ?? "http://localhost:3001"; + +test.describe("Health endpoint", () => { + test("returns 200 with healthy status when build is fresh", async ({ request }) => { + const res = await request.get(`${BASE}/api/health`); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.status).toBe("healthy"); + expect(body.checks).toHaveProperty("ssr_imports", "ok"); + expect(body.checks).toHaveProperty("ergon_api"); + expect(body.build).toHaveProperty("nodeEnv"); + expect(body.build).toHaveProperty("pid"); + expect(typeof body.build.pid).toBe("number"); + }); + + test("response schema includes all expected fields", async ({ request }) => { + const res = await request.get(`${BASE}/api/health`); + const body = await res.json(); + + expect(body).toHaveProperty("status"); + expect(body).toHaveProperty("checks"); + expect(body).toHaveProperty("build"); + expect(["healthy", "degraded"]).toContain(body.status); + + for (const value of Object.values(body.checks)) { + expect(["ok", "fail"]).toContain(value); + } + }); + + test("SSR import check exercises the actual module graph", async ({ request }) => { + const res = await request.get(`${BASE}/api/health`); + const body = await res.json(); + + expect(body.checks.ssr_imports).toBe("ok"); + if (body.checks.ssr_imports === "fail") { + expect(body.errors).toBeDefined(); + expect(body.errors.length).toBeGreaterThan(0); + expect(body.errors[0]).toContain("SSR import"); + } + }); +}); + +test.describe("Build health toast (UI)", () => { + test("toast is hidden when build is healthy", async ({ page }) => { + await page.goto(`${BASE}/`); + await page.waitForLoadState("networkidle"); + + const toast = page.locator('[data-testid="build-health-toast"]'); + await expect(toast).not.toBeVisible(); + }); +}); diff --git a/ergon-dashboard/tests/e2e/minif2f.smoke.spec.ts b/ergon-dashboard/tests/e2e/minif2f.smoke.spec.ts index 49f8323b..162b67a6 100644 --- a/ergon-dashboard/tests/e2e/minif2f.smoke.spec.ts +++ b/ergon-dashboard/tests/e2e/minif2f.smoke.spec.ts @@ -1,8 +1,7 @@ /** * Canonical smoke Playwright spec for the minif2f leg. * - * 3 happy-path cohort runs. No sad slot. All assertions in the - * shared factory (./._shared/smoke.ts). + * One canonical sad-path run. All assertions live in the shared factory. */ import { defineSmokeSpec } from "./_shared/smoke"; diff --git a/ergon-dashboard/tests/e2e/researchrubrics.smoke.spec.ts b/ergon-dashboard/tests/e2e/researchrubrics.smoke.spec.ts index 6a29289f..80955747 100644 --- a/ergon-dashboard/tests/e2e/researchrubrics.smoke.spec.ts +++ b/ergon-dashboard/tests/e2e/researchrubrics.smoke.spec.ts @@ -1,8 +1,7 @@ /** * Canonical smoke Playwright spec for the researchrubrics leg. * - * Cohort shape: 2 happy + 1 sad (see docs/superpowers/plans/test-refactor/00-program.md §3.2). - * All assertions defined in the shared factory. + * One canonical sad-path run. All assertions live in the shared factory. */ import { defineSmokeSpec } from "./_shared/smoke"; diff --git a/ergon-dashboard/tests/e2e/run.delta.spec.ts b/ergon-dashboard/tests/e2e/run.delta.spec.ts index ef3d2a4c..a1b4f239 100644 --- a/ergon-dashboard/tests/e2e/run.delta.spec.ts +++ b/ergon-dashboard/tests/e2e/run.delta.spec.ts @@ -4,14 +4,31 @@ import { createDashboardSeed, createDeltaContextEvent, createDeltaThread, + createEmptyCriteriaEvaluation, createUpdatedEvaluation, FIXTURE_IDS, } from "../helpers/dashboardFixtures"; -import { resetHarness, seedHarness } from "../helpers/harnessClient"; +import { acquireHarnessLock, resetHarness, seedHarness } from "../helpers/harnessClient"; + +test.describe.configure({ mode: "serial" }); + +let releaseHarnessLock: (() => Promise) | null = null; test.beforeEach(async ({ request }) => { - await resetHarness(request); - await seedHarness(request, createDashboardSeed()); + releaseHarnessLock = await acquireHarnessLock(); + try { + await resetHarness(request); + await seedHarness(request, createDashboardSeed()); + } catch (error) { + await releaseHarnessLock(); + releaseHarnessLock = null; + throw error; + } +}); + +test.afterEach(async () => { + await releaseHarnessLock?.(); + releaseHarnessLock = null; }); test("run header reacts to controlled completion delta", async ({ page }) => { @@ -56,12 +73,15 @@ test("communication and evaluation react to controlled deltas", async ({ page }) }); expect(evaluationResponse.ok()).toBeTruthy(); + await page.getByTestId("workspace-tab-communication").click(); await expect(page.getByTestId("workspace-communication")).toContainText( "I am rewriting the final proof around that parity split now.", ); + await page.getByTestId("workspace-tab-evaluation").click(); await expect(page.getByTestId("workspace-evaluation")).toContainText( "The updated proof compiles cleanly and closes every goal", ); + await page.getByTestId("workspace-tab-actions").click(); await expect(page.getByTestId("workspace-actions")).not.toContainText( "I am rewriting the final proof around that parity split now.", ); @@ -71,6 +91,7 @@ test("workspace actions react to controlled context event deltas", async ({ page await page.goto(`/cohorts/${FIXTURE_IDS.cohortId}/runs/${FIXTURE_IDS.runId}`); await page.getByTestId(`graph-node-${FIXTURE_IDS.solveTaskId}`).click(); + await page.getByTestId("workspace-tab-actions").click(); await expect(page.getByTestId("workspace-actions")).toContainText("lean_check"); const response = await page.request.post("/api/test/dashboard/events/context-event", { data: { @@ -83,3 +104,25 @@ test("workspace actions react to controlled context event deltas", async ({ page await expect(page.getByTestId("workspace-actions")).toContainText("lake_build"); }); + +test("evaluation tab shows a clear empty criteria state", async ({ page }) => { + await page.goto(`/cohorts/${FIXTURE_IDS.cohortId}/runs/${FIXTURE_IDS.runId}`); + await page.getByTestId(`graph-node-${FIXTURE_IDS.solveTaskId}`).click(); + + const response = await page.request.post("/api/test/dashboard/events/task-evaluation", { + data: { + runId: FIXTURE_IDS.runId, + taskId: FIXTURE_IDS.solveTaskId, + evaluation: createEmptyCriteriaEvaluation(), + }, + }); + expect(response.ok()).toBeTruthy(); + + await page.getByTestId("workspace-tab-evaluation").click(); + await expect(page.getByTestId("evaluation-criteria-empty")).toContainText( + "No evaluation criteria recorded yet", + ); + await expect(page.getByTestId("evaluation-criteria-empty")).toContainText( + "This task has no criterionResults in the persisted evaluation payload.", + ); +}); diff --git a/ergon-dashboard/tests/e2e/run.snapshot.spec.ts b/ergon-dashboard/tests/e2e/run.snapshot.spec.ts index 7db058f6..f0ef092a 100644 --- a/ergon-dashboard/tests/e2e/run.snapshot.spec.ts +++ b/ergon-dashboard/tests/e2e/run.snapshot.spec.ts @@ -1,13 +1,42 @@ import { expect, test } from "@playwright/test"; -import { createDashboardSeed, FIXTURE_IDS } from "../helpers/dashboardFixtures"; -import { resetHarness, seedHarness } from "../helpers/harnessClient"; +import { + CONCURRENT_MAS_FIXTURE_IDS, + createDashboardSeed, + FIXTURE_IDS, +} from "../helpers/dashboardFixtures"; +import { acquireHarnessLock, resetHarness, seedHarness } from "../helpers/harnessClient"; + +test.describe.configure({ mode: "serial" }); + +let releaseHarnessLock: (() => Promise) | null = null; test.beforeEach(async ({ request }) => { - await resetHarness(request); - await seedHarness(request, createDashboardSeed()); + releaseHarnessLock = await acquireHarnessLock(); + try { + await resetHarness(request); + await seedHarness(request, createDashboardSeed()); + } catch (error) { + await releaseHarnessLock(); + releaseHarnessLock = null; + throw error; + } }); +test.afterEach(async () => { + await releaseHarnessLock?.(); + releaseHarnessLock = null; +}); + +async function expectNoTimelinePlaybackControls(page: import("@playwright/test").Page) { + await expect(page.getByTestId("mode-live")).toHaveCount(0); + await expect(page.getByTestId("mode-timeline")).toHaveCount(0); + await expect(page.getByTestId("activity-play-toggle")).toHaveCount(0); + await expect(page.getByTestId("activity-speed-control")).toHaveCount(0); + await expect(page.getByTestId("activity-step-back")).toHaveCount(0); + await expect(page.getByTestId("activity-step-forward")).toHaveCount(0); +} + test("run page keeps cohort breadcrumb context", async ({ page }) => { await page.goto(`/cohorts/${FIXTURE_IDS.cohortId}/runs/${FIXTURE_IDS.runId}`); @@ -17,6 +46,66 @@ test("run page keeps cohort breadcrumb context", async ({ page }) => { await expect(page.getByTestId("run-header")).toContainText("parallel"); }); +test("run workspace does not expose manual live or timeline mode controls", async ({ page }) => { + await page.goto(`/cohorts/${FIXTURE_IDS.cohortId}/runs/${FIXTURE_IDS.runId}`); + + await expect(page.getByTestId("graph-canvas")).toBeVisible(); + await expectNoTimelinePlaybackControls(page); +}); + +test("run workspace shows rerun as unavailable until backend support exists", async ({ page }) => { + await page.goto(`/cohorts/${FIXTURE_IDS.cohortId}/runs/${FIXTURE_IDS.runId}`); + + const rerunButton = page.getByTestId("rerun-button"); + await expect(rerunButton).toBeVisible(); + await expect(rerunButton).toBeDisabled(); + await expect(rerunButton).toHaveAttribute("title", /not wired/i); +}); + +test("snapshot selection does not expose playback or speed controls", async ({ page }) => { + await page.goto( + `/cohorts/${CONCURRENT_MAS_FIXTURE_IDS.cohortId}/runs/${CONCURRENT_MAS_FIXTURE_IDS.runId}`, + ); + + await expect(page.getByTestId("activity-stack-region")).toBeVisible(); + const activity = page.locator('[data-activity-id^="graph:"]').first(); + await expect(activity).toBeVisible(); + await activity.click(); + + await expect(page.getByTestId("activity-current-sequence")).toContainText("replay"); + await expectNoTimelinePlaybackControls(page); +}); + +test("activity marker locks graph and header to snapshot until Escape returns to live", async ({ + page, +}) => { + await page.goto( + `/cohorts/${CONCURRENT_MAS_FIXTURE_IDS.cohortId}/runs/${CONCURRENT_MAS_FIXTURE_IDS.runId}`, + ); + + await expect(page.getByTestId("graph-canvas")).toBeVisible(); + const validateCitationsNode = page.getByTestId( + "graph-node-10000000-0000-4000-8000-000000000006", + ); + await expect(validateCitationsNode).toHaveAttribute("data-task-status", "completed"); + + const snapshotMarker = page.getByTestId( + "activity-bar-graph-70000000-0000-4000-8000-000000000014", + ); + await expect(snapshotMarker).toBeVisible(); + await snapshotMarker.click(); + + await expect(page.getByTestId("snapshot-lock-label")).toBeVisible(); + await expect(page.getByTestId("snapshot-pin").first()).toBeVisible(); + await expect(page.getByTestId("run-header")).toContainText("snapshot · seq 14"); + await expect(validateCitationsNode).toHaveAttribute("data-task-status", "pending"); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("run-header")).toContainText(/live/i); + await expect(page.getByTestId("snapshot-lock-label")).toHaveCount(0); + await expect(validateCitationsNode).toHaveAttribute("data-task-status", "completed"); +}); + test("graph selection opens workspace evidence sections", async ({ page }) => { await page.goto(`/cohorts/${FIXTURE_IDS.cohortId}/runs/${FIXTURE_IDS.runId}`); @@ -26,16 +115,80 @@ test("graph selection opens workspace evidence sections", async ({ page }) => { await expect(page.getByTestId("workspace-header")).toContainText("Write proof"); await expect(page.getByTestId("workspace-close")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-overview")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-actions")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-communication")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-outputs")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-transitions")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-evaluation")).toBeVisible(); + await expect(page.getByTestId("workspace-overview")).toBeVisible(); + await expect(page.getByTestId("workspace-actions")).toHaveCount(0); + + const overviewTab = page.getByTestId("workspace-tab-overview"); + const actionsTab = page.getByTestId("workspace-tab-actions"); + await expect(overviewTab).toHaveAttribute("id", "workspace-tab-button-overview"); + await expect(overviewTab).toHaveAttribute("aria-controls", "workspace-tab-panel-overview"); + await expect(overviewTab).toHaveAttribute("aria-selected", "true"); + await expect(page.locator("#workspace-tab-panel-overview")).toHaveAttribute("role", "tabpanel"); + await expect(page.locator("#workspace-tab-panel-overview")).toHaveAttribute( + "aria-labelledby", + "workspace-tab-button-overview", + ); + await expect(page.locator("#workspace-tab-panel-overview")).toHaveAttribute("tabindex", "0"); + + await overviewTab.focus(); + await page.keyboard.press("ArrowRight"); + await expect(actionsTab).toBeFocused(); + await expect(actionsTab).toHaveAttribute("aria-selected", "true"); + await expect(page.locator("#workspace-tab-panel-actions")).toHaveAttribute("role", "tabpanel"); + + await page.getByTestId("workspace-tab-actions").click(); await expect(page.getByTestId("workspace-actions")).toContainText("lean_check"); + await expect(page.getByTestId("workspace-action-card").first()).toBeVisible(); + await expect(page.getByTestId("workspace-action-summary").first()).toContainText("Tool call"); + await expect(page.getByTestId("workspace-action-payload").first()).toContainText("Arguments"); + await expect(page.getByTestId("workspace-executions")).toContainText("Attempt 1"); + await expect(page.getByTestId("workspace-sandbox")).toContainText("lake env lean proof.lean"); + + await page.getByTestId("workspace-tab-communication").click(); + await expect(page.getByTestId("communication-thread-list")).toBeVisible(); + await expect(page.getByTestId("communication-thread-card").first()).toContainText("task_clarification"); + await expect(page.getByTestId("communication-chat-trace")).toBeVisible(); + await expect(page.getByTestId("communication-chat-message").first()).toBeVisible(); + const communicationLayout = await page.evaluate(() => { + const list = document.querySelector('[data-testid="communication-thread-list"]'); + const chat = document.querySelector('[data-testid="communication-chat-trace"]'); + if (!list || !chat) return null; + const listBox = list.getBoundingClientRect(); + const chatBox = chat.getBoundingClientRect(); + return { listBottom: listBox.bottom, chatTop: chatBox.top }; + }); + expect(communicationLayout).not.toBeNull(); + expect(communicationLayout!.chatTop).toBeGreaterThanOrEqual(communicationLayout!.listBottom); + await expect + .poll(async () => + page.getByTestId("workspace-communication").evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })), + ) + .toEqual(expect.objectContaining({ scrollWidth: expect.any(Number) })); + const communicationOverflow = await page + .getByTestId("workspace-communication") + .evaluate((element) => element.scrollWidth - element.clientWidth); + expect(communicationOverflow).toBeLessThanOrEqual(1); await expect(page.getByTestId("workspace-communication")).toContainText( "Can I use the standard divisibility lemma here?", ); + + await page.getByTestId("workspace-tab-evaluation").click(); + await expect(page.getByTestId("workspace-evaluation")).toBeVisible(); await expect(page.getByTestId("workspace-evaluation")).toContainText( "Proof compiles and closes all goals", ); + + await page.getByTestId("workspace-tab-outputs").click(); await expect(page.getByTestId("workspace-outputs")).toContainText("proof.lean"); - await expect(page.getByTestId("workspace-executions")).toContainText("Attempt 1"); - await expect(page.getByTestId("workspace-sandbox")).toContainText("lake env lean proof.lean"); }); test("persisted run snapshot remains inspectable after refresh", async ({ page }) => { @@ -46,6 +199,8 @@ test("persisted run snapshot remains inspectable after refresh", async ({ page } await page.getByTestId(`graph-node-${FIXTURE_IDS.solveTaskId}`).click(); await expect(page.getByTestId("workspace-header")).toContainText("Write proof"); + await page.getByTestId("workspace-tab-outputs").click(); await expect(page.getByTestId("workspace-outputs")).toContainText("proof.lean"); + await page.getByTestId("workspace-tab-actions").click(); await expect(page.getByTestId("workspace-executions")).toContainText("Attempt 1"); }); diff --git a/ergon-dashboard/tests/e2e/swebench-verified.smoke.spec.ts b/ergon-dashboard/tests/e2e/swebench-verified.smoke.spec.ts index 34ef8787..1e48c762 100644 --- a/ergon-dashboard/tests/e2e/swebench-verified.smoke.spec.ts +++ b/ergon-dashboard/tests/e2e/swebench-verified.smoke.spec.ts @@ -1,8 +1,7 @@ /** * Canonical smoke Playwright spec for the swebench-verified leg. * - * 3 happy-path cohort runs. No sad slot. All assertions in the - * shared factory (./._shared/smoke.ts). + * One canonical sad-path run. All assertions live in the shared factory. */ import { defineSmokeSpec } from "./_shared/smoke"; diff --git a/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json b/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json index 7b6e08a9..33275ab5 100644 --- a/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json +++ b/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json @@ -551,7 +551,7 @@ "10000000-0000-4000-8000-000000000003", "10000000-0000-4000-8000-000000000004" ], - "expectedMaxConcurrency": 5, + "expectedMaxConcurrency": 1, "selectedTime": "2026-04-26T12:00:10.000Z", "hiddenFutureResourceName": "references.md" }, @@ -565,7 +565,7 @@ "10000000-0000-4000-8000-000000000005", "10000000-0000-4000-8000-000000000006" ], - "expectedMaxConcurrency": 5, + "expectedMaxConcurrency": 2, "selectedTime": "2026-04-26T12:00:18.000Z", "hiddenFutureResourceName": "references.md" } diff --git a/ergon-dashboard/tests/helpers/dashboardFixtures.ts b/ergon-dashboard/tests/helpers/dashboardFixtures.ts index ca91b6f5..3173b575 100644 --- a/ergon-dashboard/tests/helpers/dashboardFixtures.ts +++ b/ergon-dashboard/tests/helpers/dashboardFixtures.ts @@ -395,6 +395,22 @@ export function createUpdatedEvaluation(): TaskEvaluationState { }; } +export function createEmptyCriteriaEvaluation(): TaskEvaluationState { + return { + id: FIXTURE_IDS.evaluationId, + runId: FIXTURE_IDS.runId, + taskId: FIXTURE_IDS.solveTaskId, + totalScore: 0, + maxScore: 0, + normalizedScore: 0, + stagesEvaluated: 0, + stagesPassed: 0, + failedGate: null, + createdAt: "2026-03-18T12:00:31.000Z", + criterionResults: [], + }; +} + export function createDashboardSeed(): DashboardHarnessSeedPayload { const runState = serializedRunState(); const summary = { @@ -404,18 +420,18 @@ export function createDashboardSeed(): DashboardHarnessSeedPayload { created_by: "playwright", created_at: "2026-03-18T11:59:00.000Z", status: "active" as const, - total_runs: 1, + total_runs: 3, status_counts: { pending: 0, - executing: 1, + executing: 0, evaluating: 0, - completed: 0, + completed: 3, failed: 0, }, - average_score: null, - best_score: null, - worst_score: null, - average_duration_ms: null, + average_score: 1, + best_score: 1, + worst_score: 1, + average_duration_ms: 24_000, failure_rate: 0, metadata_summary: { code_commit_sha: "abc1234", @@ -450,12 +466,44 @@ export function createDashboardSeed(): DashboardHarnessSeedPayload { definition_id: FIXTURE_IDS.experimentId, cohort_id: FIXTURE_IDS.cohortId, cohort_name: summary.name, - status: "executing", + status: "completed", created_at: "2026-03-18T11:59:30.000Z", started_at: "2026-03-18T12:00:00.000Z", - completed_at: null, + completed_at: "2026-03-18T12:00:24.000Z", running_time_ms: 24_000, - final_score: null, + final_score: 1, + total_tasks: 10, + total_cost_usd: 0.12, + error_message: null, + }, + { + run_id: "22222222-2222-4222-8222-222222222223", + definition_id: FIXTURE_IDS.experimentId, + cohort_id: FIXTURE_IDS.cohortId, + cohort_name: summary.name, + status: "completed", + created_at: "2026-03-18T12:00:30.000Z", + started_at: "2026-03-18T12:01:00.000Z", + completed_at: "2026-03-18T12:01:22.000Z", + running_time_ms: 22_000, + final_score: 1, + total_tasks: 10, + total_cost_usd: 0.14, + error_message: null, + }, + { + run_id: "22222222-2222-4222-8222-222222222224", + definition_id: FIXTURE_IDS.experimentId, + cohort_id: FIXTURE_IDS.cohortId, + cohort_name: summary.name, + status: "completed", + created_at: "2026-03-18T12:01:30.000Z", + started_at: "2026-03-18T12:02:00.000Z", + completed_at: "2026-03-18T12:02:26.000Z", + running_time_ms: 26_000, + final_score: 1, + total_tasks: 10, + total_cost_usd: 0.16, error_message: null, }, ], @@ -532,6 +580,8 @@ function createConcurrentMasSeedOnly(): DashboardHarnessSeedPayload { completed_at: null, running_time_ms: 30_000, final_score: null, + total_tasks: null, + total_cost_usd: null, error_message: null, }, ], diff --git a/ergon-dashboard/tests/helpers/harnessClient.ts b/ergon-dashboard/tests/helpers/harnessClient.ts index 3171a313..4b905c93 100644 --- a/ergon-dashboard/tests/helpers/harnessClient.ts +++ b/ergon-dashboard/tests/helpers/harnessClient.ts @@ -1,7 +1,49 @@ import type { APIRequestContext } from "@playwright/test"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { DashboardHarnessSeedPayload } from "../../src/lib/testing/dashboardHarness"; +const HARNESS_LOCK_DIR = join(tmpdir(), "ergon-dashboard-shared-harness.lock"); +const HARNESS_LOCK_TIMEOUT_MS = 30_000; +const HARNESS_LOCK_RETRY_MS = 50; + +function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function getErrorCode(error: unknown): string | null { + if (typeof error !== "object" || error === null || !("code" in error)) { + return null; + } + return String(error.code); +} + +export async function acquireHarnessLock(): Promise<() => Promise> { + const startedAt = Date.now(); + + while (true) { + try { + await mkdir(HARNESS_LOCK_DIR); + return async () => { + await rm(HARNESS_LOCK_DIR, { force: true, recursive: true }); + }; + } catch (error) { + const code = getErrorCode(error); + if (code !== "EEXIST") { + throw error; + } + if (Date.now() - startedAt > HARNESS_LOCK_TIMEOUT_MS) { + throw new Error("Timed out waiting for dashboard harness lock"); + } + await delay(HARNESS_LOCK_RETRY_MS); + } + } +} + export async function resetHarness(request: APIRequestContext) { const response = await request.post("/api/test/dashboard/reset"); if (!response.ok()) { diff --git a/ergon_cli/ergon_cli/commands/eval.py b/ergon_cli/ergon_cli/commands/eval.py index c3875728..f93c4235 100644 --- a/ergon_cli/ergon_cli/commands/eval.py +++ b/ergon_cli/ergon_cli/commands/eval.py @@ -20,7 +20,7 @@ async def _watch(args: Namespace) -> int: await watch_and_evaluate( checkpoint_dir=args.checkpoint_dir, benchmark_type=args.benchmark, - evaluator_type=args.evaluator or "stub-rubric", + evaluator_type=args.evaluator, model_base=args.model_base, poll_interval_s=args.poll_interval, eval_limit=args.eval_limit, @@ -35,7 +35,7 @@ async def _checkpoint(args: Namespace) -> int: await evaluate_checkpoint( checkpoint_path=args.checkpoint, benchmark_type=args.benchmark, - evaluator_type=args.evaluator or "stub-rubric", + evaluator_type=args.evaluator, model_base=args.model_base, eval_limit=args.eval_limit, ) diff --git a/ergon_cli/ergon_cli/composition/__init__.py b/ergon_cli/ergon_cli/composition/__init__.py index f3ad39e6..2f872393 100644 --- a/ergon_cli/ergon_cli/composition/__init__.py +++ b/ergon_cli/ergon_cli/composition/__init__.py @@ -39,8 +39,8 @@ def build_experiment( # otherwise ``task_execution_service._prepare_graph_native`` will # raise ``ConfigurationError: No ExperimentDefinitionWorker with # binding_key='{env}-smoke-leaf'`` when the first subtask fires. - # ``researchrubrics-sadpath-smoke-worker`` additionally needs the - # failing leaf binding so ``l_2`` can resolve. + # ``{env}-sadpath-smoke-worker`` additionally needs the failing leaf + # binding so ``l_2`` can resolve. if _is_smoke_worker(worker_slug): return _build_smoke_experiment( benchmark=benchmark, diff --git a/ergon_cli/ergon_cli/main.py b/ergon_cli/ergon_cli/main.py index a6ad920d..69f4d054 100644 --- a/ergon_cli/ergon_cli/main.py +++ b/ergon_cli/ergon_cli/main.py @@ -86,8 +86,8 @@ def build_parser() -> argparse.ArgumentParser: eval_watch = eval_sub.add_parser("watch", help="Watch for new checkpoints and evaluate") eval_watch.add_argument("--checkpoint-dir", required=True, help="Directory to watch") eval_watch.add_argument("--benchmark", required=True, help="Benchmark slug") - eval_watch.add_argument("--evaluator", default=None, help="Evaluator slug") - eval_watch.add_argument("--model-base", default=None, help="Base model for local eval") + eval_watch.add_argument("--evaluator", required=True, help="Evaluator slug") + eval_watch.add_argument("--model-base", required=True, help="Base model for local eval") eval_watch.add_argument("--poll-interval", type=int, default=60, help="Seconds between scans") eval_watch.add_argument("--eval-limit", type=int, default=None, help="Max tasks per eval") eval_watch.add_argument( @@ -99,8 +99,8 @@ def build_parser() -> argparse.ArgumentParser: eval_ckpt = eval_sub.add_parser("checkpoint", help="Evaluate a single checkpoint") eval_ckpt.add_argument("--checkpoint", required=True, help="Checkpoint path") eval_ckpt.add_argument("--benchmark", required=True, help="Benchmark slug") - eval_ckpt.add_argument("--evaluator", default=None, help="Evaluator slug") - eval_ckpt.add_argument("--model-base", default=None, help="Base model for local eval") + eval_ckpt.add_argument("--evaluator", required=True, help="Evaluator slug") + eval_ckpt.add_argument("--model-base", required=True, help="Base model for local eval") eval_ckpt.add_argument("--eval-limit", type=int, default=None, help="Max tasks") # -- onboard / doctor ------------------------------------------------------ diff --git a/ergon_core/ergon_core/core/api/app.py b/ergon_core/ergon_core/core/api/app.py index d6fab4c1..c251d1d4 100644 --- a/ergon_core/ergon_core/core/api/app.py +++ b/ergon_core/ergon_core/core/api/app.py @@ -24,6 +24,7 @@ from ergon_core.core.api.rollouts import init_service as init_rollout_service from ergon_core.core.api.rollouts import router as rollouts_router from ergon_core.core.api.runs import router as runs_router +from ergon_core.core.api.startup_plugins import run_startup_plugins from ergon_core.core.api.test_harness import router as _test_harness_router from ergon_core.core.dashboard.emitter import dashboard_emitter from ergon_core.core.persistence.shared.db import ensure_db, get_session @@ -93,19 +94,6 @@ async def lifespan(app: FastAPI): if settings.enable_test_harness: app.include_router(_test_harness_router) -if settings.smoke_fixtures_enabled: - # Register the canonical-smoke WORKERS / EVALUATORS into this - # process's registry dicts. Inngest's ``worker_execute_fn`` runs - # inside this container, so if the smoke fixtures are only imported - # host-side (in pytest's process) the container's dicts stay empty - # and every smoke run fails at worker resolution. The flag is - # separate from ``ENABLE_TEST_HARNESS`` because real-LLM rollouts - # need the read-only harness endpoints without replacing production - # benchmark registries with smoke fixtures. - # Test-support package kept outside ``tests`` so runtime entrypoints - # never import pytest-owned modules. - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures - - register_smoke_fixtures() +run_startup_plugins(settings.startup_plugins) inngest.fast_api.serve(app, inngest_client, ALL_FUNCTIONS) diff --git a/ergon_core/ergon_core/core/api/runs.py b/ergon_core/ergon_core/core/api/runs.py index b9457164..38a46b67 100644 --- a/ergon_core/ergon_core/core/api/runs.py +++ b/ergon_core/ergon_core/core/api/runs.py @@ -289,6 +289,7 @@ def _task_keyed_sandboxes( def _build_communication_threads( threads: list[Thread], messages: list[ThreadMessage], + execution_task_map: dict[UUID, UUID], ) -> list[RunCommunicationThreadDto]: msgs_by_thread: dict[UUID, list[ThreadMessage]] = defaultdict(list) for m in sorted(messages, key=lambda m: m.sequence_num): @@ -296,11 +297,22 @@ def _build_communication_threads( result: list[RunCommunicationThreadDto] = [] for t in threads: + thread_messages = msgs_by_thread.get(t.id, []) + task_ids = { + task_id + for message in thread_messages + if message.task_execution_id is not None + for task_id in [execution_task_map.get(message.task_execution_id)] + if task_id is not None + } + thread_task_id = next(iter(task_ids)) if len(task_ids) == 1 else None result.append( RunCommunicationThreadDto( id=str(t.id), run_id=str(t.run_id), + task_id=str(thread_task_id) if thread_task_id else None, topic=t.topic, + summary=t.summary, agent_a_id=t.agent_a_id, agent_b_id=t.agent_b_id, created_at=t.created_at, @@ -311,6 +323,12 @@ def _build_communication_threads( thread_id=str(m.thread_id), run_id=str(m.run_id), thread_topic=t.topic, + task_id=( + str(execution_task_map[m.task_execution_id]) + if m.task_execution_id + and m.task_execution_id in execution_task_map + else None + ), task_execution_id=str(m.task_execution_id) if m.task_execution_id else None, from_agent_id=m.from_agent_id, to_agent_id=m.to_agent_id, @@ -318,7 +336,7 @@ def _build_communication_threads( sequence_num=m.sequence_num, created_at=m.created_at, ) - for m in msgs_by_thread.get(t.id, []) + for m in thread_messages ], ) ) diff --git a/ergon_core/ergon_core/core/api/schemas.py b/ergon_core/ergon_core/core/api/schemas.py index 9de4bfa3..bc524176 100644 --- a/ergon_core/ergon_core/core/api/schemas.py +++ b/ergon_core/ergon_core/core/api/schemas.py @@ -140,6 +140,7 @@ class RunCommunicationThreadDto(CamelModel): run_id: str task_id: str | None = None topic: str + summary: str | None = None agent_a_id: str agent_b_id: str created_at: datetime diff --git a/ergon_core/ergon_core/core/api/startup_plugins.py b/ergon_core/ergon_core/core/api/startup_plugins.py new file mode 100644 index 00000000..3573d910 --- /dev/null +++ b/ergon_core/ergon_core/core/api/startup_plugins.py @@ -0,0 +1,16 @@ +"""Optional startup plugin loader.""" + +from importlib import import_module + + +def run_startup_plugins(plugin_specs: tuple[str, ...]) -> None: + for spec in plugin_specs: + module_name, sep, attr_name = spec.partition(":") + if not sep or not module_name or not attr_name: + raise RuntimeError( + "Invalid ERGON_STARTUP_PLUGINS entry " + f"{spec!r}; expected 'module:function'" + ) + module = import_module(module_name) + plugin = getattr(module, attr_name) + plugin() diff --git a/ergon_core/ergon_core/core/persistence/telemetry/models.py b/ergon_core/ergon_core/core/persistence/telemetry/models.py index 03330b37..6eb9dc7c 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/models.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/models.py @@ -386,6 +386,7 @@ class Thread(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) run_id: UUID = Field(foreign_key="runs.id", index=True) topic: str + summary: str | None = None agent_a_id: str = Field(index=True) agent_b_id: str = Field(index=True) created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) diff --git a/ergon_core/ergon_core/core/providers/sandbox/__init__.py b/ergon_core/ergon_core/core/providers/sandbox/__init__.py index 80381500..6a0a5e62 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/__init__.py +++ b/ergon_core/ergon_core/core/providers/sandbox/__init__.py @@ -1,35 +1,8 @@ -"""Sandbox management: provisioning, file I/O, lifecycle.""" +"""Sandbox management: provisioning, file I/O, lifecycle. -from ergon_core.core.providers.sandbox.errors import ( - SandboxError, - SandboxExpiredError, - SandboxSetupError, -) -from ergon_core.core.providers.sandbox.event_sink import ( - CompoundSandboxEventSink, - DashboardEmitterSandboxEventSink, - NoopSandboxEventSink, - PostgresSandboxEventSink, - SandboxEventSink, -) -from ergon_core.core.providers.sandbox.manager import ( - BaseSandboxManager, - DefaultSandboxManager, - DownloadedFile, - DownloadedFiles, -) +Import concrete modules directly, for example +``ergon_core.core.providers.sandbox.manager``. Keeping this package initializer +lightweight avoids import cycles between telemetry models and API DTO modules. +""" -__all__ = [ - "BaseSandboxManager", - "CompoundSandboxEventSink", - "DashboardEmitterSandboxEventSink", - "DefaultSandboxManager", - "DownloadedFile", - "DownloadedFiles", - "NoopSandboxEventSink", - "PostgresSandboxEventSink", - "SandboxError", - "SandboxEventSink", - "SandboxExpiredError", - "SandboxSetupError", -] +__all__: list[str] = [] diff --git a/ergon_core/ergon_core/core/providers/sandbox/event_sink.py b/ergon_core/ergon_core/core/providers/sandbox/event_sink.py index e2d9aa6c..2239e49d 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/event_sink.py +++ b/ergon_core/ergon_core/core/providers/sandbox/event_sink.py @@ -4,7 +4,6 @@ from uuid import UUID from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.telemetry.models import SandboxCommandWalEntry, SandboxEvent @runtime_checkable @@ -151,6 +150,8 @@ async def sandbox_created( timeout_minutes: int, template: str | None = None, ) -> None: + from ergon_core.core.persistence.telemetry.models import SandboxEvent + with get_session() as s: s.add( SandboxEvent( @@ -175,6 +176,8 @@ async def sandbox_command( exit_code: int | None = None, duration_ms: int | None = None, ) -> None: + from ergon_core.core.persistence.telemetry.models import SandboxCommandWalEntry + with get_session() as s: s.add( SandboxCommandWalEntry( @@ -199,6 +202,8 @@ async def sandbox_closed( ) -> None: if run_id is None: return + from ergon_core.core.persistence.telemetry.models import SandboxEvent + with get_session() as s: s.add( SandboxEvent( diff --git a/ergon_core/ergon_core/core/providers/sandbox/lifecycle.py b/ergon_core/ergon_core/core/providers/sandbox/lifecycle.py new file mode 100644 index 00000000..ed13af23 --- /dev/null +++ b/ergon_core/ergon_core/core/providers/sandbox/lifecycle.py @@ -0,0 +1,55 @@ +"""Runtime-facing sandbox lifecycle helpers.""" + +from __future__ import annotations + +import logging +from enum import StrEnum + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +class SandboxTerminationReason(StrEnum): + TERMINATED = "terminated" + NOT_FOUND_OR_ALREADY_CLOSED = "not_found_or_already_closed" + MISSING_ID = "missing_id" + ERROR = "error" + + +class SandboxTerminationResult(BaseModel): + sandbox_id: str | None + terminated: bool + reason: SandboxTerminationReason + + +async def terminate_sandbox_by_id(sandbox_id: str | None) -> SandboxTerminationResult: + """Terminate a sandbox behind a single runtime-facing boundary.""" + if sandbox_id is None: + return SandboxTerminationResult( + sandbox_id=None, + terminated=False, + reason=SandboxTerminationReason.MISSING_ID, + ) + + try: + from ergon_core.core.providers.sandbox.manager import BaseSandboxManager + + terminated = await BaseSandboxManager.terminate_by_sandbox_id(sandbox_id) + except Exception: # slopcop: ignore[no-broad-except] + logger.error("Failed to terminate sandbox %s", sandbox_id, exc_info=True) + return SandboxTerminationResult( + sandbox_id=sandbox_id, + terminated=False, + reason=SandboxTerminationReason.ERROR, + ) + + return SandboxTerminationResult( + sandbox_id=sandbox_id, + terminated=terminated, + reason=( + SandboxTerminationReason.TERMINATED + if terminated + else SandboxTerminationReason.NOT_FOUND_OR_ALREADY_CLOSED + ), + ) diff --git a/ergon_core/ergon_core/core/providers/sandbox/manager.py b/ergon_core/ergon_core/core/providers/sandbox/manager.py index 406a35c0..7bbab2ab 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/manager.py +++ b/ergon_core/ergon_core/core/providers/sandbox/manager.py @@ -8,7 +8,6 @@ from typing import ClassVar, Protocol, runtime_checkable from uuid import UUID -from ergon_core.api.json_types import JsonValue from ergon_core.core.providers.sandbox.errors import SandboxExpiredError from ergon_core.core.providers.sandbox.event_sink import ( NoopSandboxEventSink, @@ -33,7 +32,7 @@ class UploadableResource(Protocol): except ImportError: AsyncSandbox = None # type: ignore[assignment,misc] - # Fallback stubs so `except (TimeoutException, SandboxNotFoundException)` + # Fallback exception classes so `except (TimeoutException, SandboxNotFoundException)` # stays syntactically valid when the e2b SDK is unavailable. They will # never actually be raised because the sandbox code paths require e2b. class _MissingE2BError(Exception): # slopcop: ignore[no-broad-except] @@ -97,7 +96,8 @@ def __init__(self) -> None: # Sink is configured process-wide via set_event_sink() in app lifespan. # Do not accept event_sink= here; the singleton pattern (see __new__ above) # makes constructor-level sink assignment a last-write-wins stomp on shared - # class state. Tests must use set_event_sink() in fixture setup. + # class state. Local verification harnesses should use set_event_sink() + # during setup. pass @classmethod @@ -105,12 +105,12 @@ def set_event_sink(cls, sink: SandboxEventSink) -> None: """Install a process-level event sink on this manager subclass. Called once during FastAPI lifespan startup for each concrete subclass. - Tests may call this in fixture setup and reset with - ``NoopSandboxEventSink()`` in teardown. + Local verification harnesses may call this during setup and reset with + ``NoopSandboxEventSink()`` during teardown. Assigns directly to ``cls._event_sink`` (not to the base class attribute), so each subclass carries its own sink and subclasses can - be individually targeted in tests. + be individually targeted by local verification harnesses. Production callers MUST NOT call this after startup. The only sanctioned call site is inside the ``lifespan`` context manager in @@ -534,6 +534,10 @@ async def terminate_by_sandbox_id(sandbox_id: str) -> bool: task_id, BaseSandboxManager, ) + if manager_cls is not BaseSandboxManager: + await manager_cls().terminate(task_id) + return True + display_task_id = BaseSandboxManager._display_task_ids.get(task_id, task_id) run_id = BaseSandboxManager._run_ids.get(task_id) try: @@ -619,98 +623,7 @@ class state. ``reconnect`` deliberately does NOT register the class DefaultSandboxManager(BaseSandboxManager): - """No custom dependencies. Used by benchmarks without specific sandbox setup. - - If ``E2B_API_KEY`` is not configured (e.g. CI stub runs) this manager - transparently delegates to ``StubSandboxManager`` -- the task still - runs, but no E2B sandbox is provisioned and the returned sandbox_id is - a well-formed stub id (see :func:`is_stub_sandbox_id`). - """ - - async def create( - self, - sandbox_key: UUID, - run_id: UUID, - timeout_minutes: int = 30, - envs: dict[str, str] | None = None, - display_task_id: UUID | None = None, - ) -> str: - if not settings.e2b_api_key: - return await StubSandboxManager().create( - sandbox_key, - run_id=run_id, - timeout_minutes=timeout_minutes, - envs=envs, - display_task_id=display_task_id, - ) - return await super().create( - sandbox_key, - run_id=run_id, - timeout_minutes=timeout_minutes, - envs=envs, - display_task_id=display_task_id, - ) + """No custom dependencies. Used by benchmarks without specific sandbox setup.""" async def _install_dependencies(self, sandbox: AsyncSandbox, task_id: UUID) -> None: pass - - -# ── Stub sandbox manager ────────────────────────────────────────────────── - -_STUB_SANDBOX_PREFIX = "stub-sandbox-" - - -def is_stub_sandbox_id(sandbox_id: JsonValue) -> bool: - """Return True iff ``sandbox_id`` was produced by :class:`StubSandboxManager`. - - Stub sandbox ids are produced by the CI / no-E2B-key code path. Any - teardown or download code that touches the E2B API must skip when this - returns True, otherwise the call will fail (no API key, no sandbox - exists on the E2B side). - - Accepts JSON values because some call sites read ``sandbox_id`` out of - persisted JSON summaries before checking whether teardown should skip. - """ - return isinstance(sandbox_id, str) and sandbox_id.startswith(_STUB_SANDBOX_PREFIX) - - -class StubSandboxManager(BaseSandboxManager): - """No-op sandbox manager used when E2B is not configured. - - ``create`` returns a synthetic id (``stub-sandbox-``). ``terminate`` - and other lifecycle methods are no-ops. Consumers that must distinguish - the stub path can call :func:`is_stub_sandbox_id` -- the sentinel string - ``"skipped"`` and the ``SandboxId = str | Literal["skipped"]`` union it - required have both been retired. - """ - - async def create( - self, - sandbox_key: UUID, - run_id: UUID, - timeout_minutes: int = 30, - envs: dict[str, str] | None = None, - display_task_id: UUID | None = None, - ) -> str: - stub_id = f"{_STUB_SANDBOX_PREFIX}{sandbox_key}" - logger.info( - "E2B_API_KEY not set — returning stub sandbox id %s for task %s (stub mode)", - stub_id, - sandbox_key, - ) - self._ensure_registries(sandbox_key) - self._run_ids[sandbox_key] = run_id - self._display_task_ids[sandbox_key] = display_task_id or sandbox_key - return stub_id - - async def _install_dependencies(self, sandbox: AsyncSandbox, task_id: UUID) -> None: - return None - - async def terminate(self, task_id: UUID, reason: str = "completed") -> None: - self._file_registries.pop(task_id, None) - self._created_files_registry.pop(task_id, None) - self._run_ids.pop(task_id, None) - self._display_task_ids.pop(task_id, None) - - async def reset_timeout(self, task_id: UUID, timeout_minutes: int = 30) -> bool: - return True diff --git a/ergon_core/ergon_core/core/rl/eval_runner.py b/ergon_core/ergon_core/core/rl/eval_runner.py index 4545bf75..748a682a 100644 --- a/ergon_core/ergon_core/core/rl/eval_runner.py +++ b/ergon_core/ergon_core/core/rl/eval_runner.py @@ -21,8 +21,8 @@ async def watch_and_evaluate( checkpoint_dir: str, benchmark_type: str, *, - evaluator_type: str = "stub-rubric", - model_base: str | None = None, + evaluator_type: str, + model_base: str, poll_interval_s: int = 60, eval_limit: int | None = None, on_checkpoint_cmd: str | None = None, @@ -94,7 +94,7 @@ async def _run_local_eval( *, benchmark_type: str, evaluator_type: str, - model_base: str | None, + model_base: str, eval_limit: int | None, ) -> int: """Run benchmark evaluation locally via the CLI. Returns exit code. @@ -102,7 +102,7 @@ async def _run_local_eval( Uses the checkpoint path as the vLLM model target so each checkpoint is actually evaluated (not just the base model). """ - model_target = f"vllm:{ckpt.path}" if model_base else "stub-worker" + model_target = f"vllm:{ckpt.path}" cmd = [ "ergon", @@ -154,8 +154,8 @@ async def evaluate_checkpoint( checkpoint_path: str, benchmark_type: str, *, - evaluator_type: str = "stub-rubric", - model_base: str | None = None, + evaluator_type: str, + model_base: str, eval_limit: int | None = None, ) -> int: """One-shot checkpoint evaluation. Returns exit code.""" diff --git a/ergon_core/ergon_core/core/runtime/events/task_events.py b/ergon_core/ergon_core/core/runtime/events/task_events.py index 87bc1b07..9fd8f217 100644 --- a/ergon_core/ergon_core/core/runtime/events/task_events.py +++ b/ergon_core/ergon_core/core/runtime/events/task_events.py @@ -8,15 +8,11 @@ from ergon_core.core.runtime.events.base import InngestEventContract -# SandboxId is just a str. Previously the type was ``str | Literal["skipped"]`` -# with a ``SANDBOX_SKIPPED = "skipped"`` sentinel returned by -# ``DefaultSandboxManager.create`` when no E2B_API_KEY was configured. That -# sentinel forced every downstream consumer to branch on a magic string. -# Stub/CI mode is now served by ``StubSandboxManager`` which returns a -# structurally identifiable ID (see ``is_stub_sandbox_id``); event payloads -# carry a plain ``str`` sandbox_id exactly like the real path, and -# ``TaskFailedEvent.sandbox_id`` is ``str | None`` because a task can fail -# before sandbox-setup runs (in which case there really is no sandbox). +# Production task execution emits real sandbox IDs. Test-support managers may +# use sentinel IDs, but core event consumers must not parse or branch on those +# sentinel formats. ``TaskFailedEvent.sandbox_id`` is ``str | None`` because a +# task can fail before sandbox setup runs, in which case there really is no +# sandbox. SandboxId = str diff --git a/ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py b/ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py index 28cdeb05..f517de7a 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py +++ b/ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py @@ -30,8 +30,8 @@ class BenchmarkRunRequest(InngestEventContract): benchmark_slug: str model: str - worker_slug: str = "stub-worker" - evaluator_slug: str = "stub-rubric" + worker_slug: str + evaluator_slug: str cohort_name: str = "" # slopcop: ignore[no-str-empty-default] diff --git a/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py b/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py index 072ad3c9..cfee52bd 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py +++ b/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py @@ -9,10 +9,7 @@ import logging import inngest -from ergon_core.core.providers.sandbox.manager import ( - BaseSandboxManager, - is_stub_sandbox_id, -) +from ergon_core.core.providers.sandbox.lifecycle import terminate_sandbox_by_id from ergon_core.core.runtime.events.task_events import ( TaskCompletedEvent, ) @@ -101,15 +98,11 @@ async def check_and_run_evaluators(ctx: inngest.Context) -> EvaluatorsResult: async def _terminate_sandbox(sandbox_id: str) -> None: - """Terminate the task's sandbox if one was created.""" - if is_stub_sandbox_id(sandbox_id): - return - try: - await BaseSandboxManager.terminate_by_sandbox_id(sandbox_id) - logger.info("Terminated sandbox %s after evaluation", sandbox_id) - except Exception: # slopcop: ignore[no-broad-except] - logger.error( - "Failed to terminate sandbox %s — potential sandbox leak", - sandbox_id, - exc_info=True, - ) + """Terminate the task's sandbox through the provider lifecycle boundary.""" + result = await terminate_sandbox_by_id(sandbox_id) + logger.info( + "Evaluator sandbox cleanup sandbox_id=%s terminated=%s reason=%s", + result.sandbox_id, + result.terminated, + result.reason, + ) diff --git a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py b/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py index e4920f43..683416e3 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py +++ b/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py @@ -2,7 +2,8 @@ Two durable steps: 1. update-db-rows — mark execution CANCELLED (idempotent) -2. release-sandbox — stub (pending sandbox management module) +2. release-sandbox — routed through the sandbox lifecycle provider when an + execution has an associated sandbox. """ import logging diff --git a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py b/ergon_core/ergon_core/core/runtime/inngest/execute_task.py index 2c091b7f..7ed8e3bc 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py +++ b/ergon_core/ergon_core/core/runtime/inngest/execute_task.py @@ -8,7 +8,6 @@ from datetime import UTC, datetime import inngest -from ergon_core.core.providers.sandbox.manager import StubSandboxManager from ergon_core.core.runtime.errors import ContractViolationError from ergon_core.core.runtime.events.task_events import ( TaskCompletedEvent, @@ -201,7 +200,7 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: # ``finalize_failure`` never runs, ``TaskFailedEvent`` is never # emitted, and the run_task_executions row is stuck in RUNNING with # ``error_json = null`` forever. Observed 2026-04-23 on every - # smoke subtask; see docs/bugs/open/2026-04-23-inngest-function-failures.md § A. + # dynamic subtask; see docs/bugs/open/2026-04-23-inngest-function-failures.md § A. prepared: PreparedTaskExecution | None = None # ``None`` until sandbox-setup returns. ``TaskFailedEvent.sandbox_id`` is # now ``str | None`` so a pre-sandbox failure carries ``None`` instead of @@ -211,27 +210,11 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: prepared = await _prepare_execution(ctx, svc, payload) if prepared.skipped: - logger.info( - "task-execute skipped task_id=%s reason=%s", - payload.task_id, - prepared.skip_reason, - ) - # ``TaskCompletedEvent.sandbox_id`` is required, so mint a stub id - # representing "this task completed without provisioning a sandbox". - # Downstream teardown uses ``is_stub_sandbox_id`` to short-circuit. - stub_sandbox_id = await StubSandboxManager().create( - prepared.node_id, - run_id=payload.run_id, - display_task_id=prepared.node_id, - ) - await _emit_task_completed(payload, prepared, stub_sandbox_id) - return TaskExecuteResult( + raise ContractViolationError( + "Skipped task execution cannot emit task/completed without a real sandbox_id. " + "Introduce a first-class task/skipped event before supporting skipped tasks.", run_id=payload.run_id, task_id=payload.task_id, - execution_id=prepared.execution_id, - success=True, - skipped=True, - skip_reason=prepared.skip_reason, ) sandbox_result = await _setup_sandbox(ctx, payload, prepared) @@ -246,6 +229,7 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: worker_result = await _run_worker(ctx, payload, prepared, sandbox_result) if not worker_result.success: + await _persist_outputs(ctx, payload, prepared, sandbox_result) raise RuntimeError(worker_result.error or "Worker execution failed") persist_result = await _persist_outputs(ctx, payload, prepared, sandbox_result) diff --git a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py b/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py index d60e3478..87b1ae89 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py +++ b/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime import inngest +from ergon_core.core.providers.sandbox.lifecycle import terminate_sandbox_by_id from ergon_core.core.runtime.events.task_events import ( TaskCancelledEvent, TaskCompletedEvent, @@ -30,6 +31,7 @@ task_propagate_context, ) + logger = logging.getLogger(__name__) @@ -160,6 +162,7 @@ async def propagate_task_failure_fn(ctx: inngest.Context) -> TaskPropagateResult node_id=payload.node_id, ) ) + await _terminate_failed_task_sandbox(payload.sandbox_id) # BLOCKED successors are a DB write only — no task/cancelled events. # propagation.invalidated_targets is always empty from the failure path. @@ -188,3 +191,13 @@ async def propagate_task_failure_fn(ctx: inngest.Context) -> TaskPropagateResult workflow_failed=(propagation.workflow_terminal_state == WorkflowTerminalState.FAILED), ) return result + + +async def _terminate_failed_task_sandbox(sandbox_id: str | None) -> None: + result = await terminate_sandbox_by_id(sandbox_id) + if not result.terminated: + logger.info( + "failed-task sandbox cleanup did not terminate sandbox_id=%s reason=%s", + result.sandbox_id, + result.reason, + ) diff --git a/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py b/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py index e433ca7e..88a83fdc 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py +++ b/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py @@ -11,10 +11,7 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import RunStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.providers.sandbox.manager import ( - BaseSandboxManager, - is_stub_sandbox_id, -) +from ergon_core.core.providers.sandbox.lifecycle import terminate_sandbox_by_id from ergon_core.core.runtime.errors import ConfigurationError, DataIntegrityError from ergon_core.core.runtime.events.infrastructure_events import RunCleanupEvent from ergon_core.core.runtime.inngest_client import inngest_client @@ -67,17 +64,12 @@ async def _cleanup_run(run_id: UUID, status: str, error_message: str | None) -> raise DataIntegrityError("RunRecord", run_id) sandbox_id = run.parsed_summary().get("sandbox_id") - sandbox_terminated = False + sandbox_result = await terminate_sandbox_by_id( + sandbox_id if isinstance(sandbox_id, str) else None + ) + sandbox_terminated = sandbox_result.terminated - if is_stub_sandbox_id(sandbox_id): - logger.info( - "run-cleanup run_id=%s: sandbox_id=%s is a stub (no E2B sandbox exists), skipping termination", - run_id, - sandbox_id, - ) - elif sandbox_id and isinstance(sandbox_id, str): - sandbox_terminated = await BaseSandboxManager.terminate_by_sandbox_id(sandbox_id) - elif sandbox_id is not None: + if sandbox_id is not None and not isinstance(sandbox_id, str): logger.warning( "run-cleanup run_id=%s: sandbox_id has unexpected type %s, skipping termination", run_id, diff --git a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py index 6d677b4d..e6c3a8e9 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py +++ b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from ergon_builtins.registry import BENCHMARKS, WORKERS from ergon_core.api.generation import GenerationTurn +from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload from ergon_core.api.worker_context import WorkerContext from ergon_core.core.dashboard.emitter import dashboard_emitter @@ -32,6 +33,14 @@ logger = logging.getLogger(__name__) +def _worker_execute_result_from_output(output: WorkerOutput) -> WorkerExecuteResult: + return WorkerExecuteResult( + success=output.success, + final_assistant_message=output.output, + error=None if output.success else output.output, + ) + + @inngest_client.create_function( fn_id="worker-execute", trigger=inngest.TriggerEvent(event="task/worker-execute"), @@ -155,10 +164,7 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: ) ) - return WorkerExecuteResult( - success=True, - final_assistant_message=output.output, - ) + return _worker_execute_result_from_output(output) async def _persist_context_events( diff --git a/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py b/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py index 8185ae24..b0a0d8da 100644 --- a/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py +++ b/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py @@ -50,6 +50,8 @@ class CohortRunRowDto(BaseModel): completed_at: datetime | None = None running_time_ms: int | None = None final_score: float | None = None + total_tasks: int | None = None + total_cost_usd: float | None = None error_message: str | None = None diff --git a/ergon_core/ergon_core/core/runtime/services/cohort_service.py b/ergon_core/ergon_core/core/runtime/services/cohort_service.py index f4176f5c..5cb73e3d 100644 --- a/ergon_core/ergon_core/core/runtime/services/cohort_service.py +++ b/ergon_core/ergon_core/core/runtime/services/cohort_service.py @@ -9,6 +9,7 @@ ExperimentCohortStatus, RunRecord, ) +from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.runtime.services.cohort_schemas import ( CohortDetailDto, CohortRunRowDto, @@ -17,7 +18,7 @@ UpdateCohortRequest, ) from ergon_core.core.utils import utcnow -from sqlmodel import select +from sqlmodel import func, select class ExperimentCohortService: @@ -79,7 +80,22 @@ def get_detail(self, cohort_id: UUID) -> CohortDetailDto | None: runs = list( session.exec(select(RunRecord).where(RunRecord.cohort_id == cohort_id)).all() ) - run_rows = [self._build_run_row(cohort, run) for run in runs] + task_counts = ( + { + run_id: count + for run_id, count in session.exec( + select(RunGraphNode.run_id, func.count(RunGraphNode.id)) + .where(RunGraphNode.run_id.in_([run.id for run in runs])) + .group_by(RunGraphNode.run_id) + ).all() + } + if runs + else {} + ) + run_rows = [ + self._build_run_row(cohort, run, int(task_counts.get(run.id, 0)) or None) + for run in runs + ] return CohortDetailDto(summary=summary, runs=run_rows) def get_summary(self, cohort_id: UUID) -> CohortSummaryDto | None: @@ -143,7 +159,11 @@ def _build_summary( ) @staticmethod - def _build_run_row(cohort: ExperimentCohort, run: RunRecord) -> CohortRunRowDto: + def _build_run_row( + cohort: ExperimentCohort, + run: RunRecord, + total_tasks: int | None = None, + ) -> CohortRunRowDto: running_time_ms: int | None = None if run.started_at is not None: end_time = run.completed_at or utcnow() @@ -152,7 +172,11 @@ def _build_run_row(cohort: ExperimentCohort, run: RunRecord) -> CohortRunRowDto: score: float | None = None summary = run.parsed_summary() if summary: - score = summary.get("normalized_score") or summary.get("final_score") + raw_score = summary.get("normalized_score") + if raw_score is None: + raw_score = summary.get("final_score") + score = float(raw_score) if isinstance(raw_score, int | float) else None + total_cost_usd = summary.get("total_cost_usd") if summary else None return CohortRunRowDto( run_id=run.id, @@ -165,6 +189,10 @@ def _build_run_row(cohort: ExperimentCohort, run: RunRecord) -> CohortRunRowDto: completed_at=run.completed_at, running_time_ms=running_time_ms, final_score=score, + total_tasks=total_tasks, + total_cost_usd=( + float(total_cost_usd) if isinstance(total_cost_usd, int | float) else None + ), error_message=run.error_message, ) diff --git a/ergon_core/ergon_core/core/runtime/services/communication_schemas.py b/ergon_core/ergon_core/core/runtime/services/communication_schemas.py index 53b12629..d16e6f77 100644 --- a/ergon_core/ergon_core/core/runtime/services/communication_schemas.py +++ b/ergon_core/ergon_core/core/runtime/services/communication_schemas.py @@ -19,6 +19,10 @@ class CreateMessageRequest(BaseModel): description="ID of the receiving agent, e.g. '{run_id}:stakeholder'", ) thread_topic: str + thread_summary: str | None = Field( + default=None, + description="Optional human-readable summary set when the thread is first created.", + ) content: str task_execution_id: UUID | None = None @@ -45,6 +49,7 @@ class ThreadSummary(BaseModel): thread_id: UUID run_id: UUID topic: str + summary: str | None = None agent_a_id: str agent_b_id: str message_count: int @@ -56,6 +61,7 @@ class ThreadWithMessages(BaseModel): thread_id: UUID run_id: UUID topic: str + summary: str | None = None agent_a_id: str agent_b_id: str messages: list[MessageResponse] diff --git a/ergon_core/ergon_core/core/runtime/services/communication_service.py b/ergon_core/ergon_core/core/runtime/services/communication_service.py index c0f5ad9f..04d06778 100644 --- a/ergon_core/ergon_core/core/runtime/services/communication_service.py +++ b/ergon_core/ergon_core/core/runtime/services/communication_service.py @@ -35,6 +35,7 @@ async def save_message(self, request: CreateMessageRequest) -> MessageResponse: agent_a_id=request.from_agent_id, agent_b_id=request.to_agent_id, topic=request.thread_topic, + thread_summary=request.thread_summary, ) seq_num = ( @@ -80,6 +81,7 @@ async def save_message(self, request: CreateMessageRequest) -> MessageResponse: id=str(thread.id), run_id=str(thread.run_id), topic=thread.topic, + summary=thread.summary, agent_a_id=thread.agent_a_id, agent_b_id=thread.agent_b_id, created_at=thread.created_at, @@ -95,6 +97,7 @@ async def save_message(self, request: CreateMessageRequest) -> MessageResponse: to_agent_id=message.to_agent_id, content=message.content, sequence_num=message.sequence_num, + task_execution_id=str(message.task_execution_id) if message.task_execution_id else None, created_at=message.created_at, ) try: @@ -153,6 +156,7 @@ def get_all_threads_for_run(self, run_id: UUID) -> list[ThreadSummary]: thread_id=thread.id, run_id=thread.run_id, topic=thread.topic, + summary=thread.summary, agent_a_id=thread.agent_a_id, agent_b_id=thread.agent_b_id, message_count=count, @@ -174,6 +178,7 @@ def get_thread_with_messages(self, thread_id: UUID) -> ThreadWithMessages | None thread_id=thread.id, run_id=thread.run_id, topic=thread.topic, + summary=thread.summary, agent_a_id=thread.agent_a_id, agent_b_id=thread.agent_b_id, messages=messages, @@ -193,6 +198,7 @@ def _get_or_create_thread( agent_a_id: str, agent_b_id: str, topic: str, + thread_summary: str | None = None, ) -> Thread: # Threads are keyed by (run_id, topic) only — all senders on the same # topic share one thread per run (broadcast/group semantics). @@ -201,10 +207,19 @@ def _get_or_create_thread( stmt = select(Thread).where(Thread.run_id == run_id).where(Thread.topic == topic) existing = session.exec(stmt).first() if existing is not None: + if existing.summary is None and thread_summary: + existing.summary = thread_summary + session.add(existing) return existing a, b = sorted([agent_a_id, agent_b_id]) - thread = Thread(run_id=run_id, topic=topic, agent_a_id=a, agent_b_id=b) + thread = Thread( + run_id=run_id, + topic=topic, + summary=thread_summary, + agent_a_id=a, + agent_b_id=b, + ) session.add(thread) try: session.flush() diff --git a/ergon_core/ergon_core/core/runtime/services/run_read_service.py b/ergon_core/ergon_core/core/runtime/services/run_read_service.py index aba8a421..f8a2b811 100644 --- a/ergon_core/ergon_core/core/runtime/services/run_read_service.py +++ b/ergon_core/ergon_core/core/runtime/services/run_read_service.py @@ -164,7 +164,11 @@ def build_run_snapshot(self, run_id: UUID) -> RunSnapshotDto | None: ), context_events_by_task=dict(context_events_by_task), sandboxes_by_task=run_api_helpers._task_keyed_sandboxes(run_summary), - threads=run_api_helpers._build_communication_threads(threads, thread_messages), + threads=run_api_helpers._build_communication_threads( + threads, + thread_messages, + execution_task_map, + ), started_at=run.started_at or run.created_at, completed_at=run.completed_at, duration_seconds=duration_seconds, diff --git a/ergon_core/ergon_core/core/settings.py b/ergon_core/ergon_core/core/settings.py index 83588e68..272d0f99 100644 --- a/ergon_core/ergon_core/core/settings.py +++ b/ergon_core/ergon_core/core/settings.py @@ -56,9 +56,9 @@ class Settings(BaseSettings): default=False, validation_alias=AliasChoices("ENABLE_TEST_HARNESS"), ) - enable_smoke_fixtures: bool | None = Field( - default=None, - validation_alias=AliasChoices("ENABLE_SMOKE_FIXTURES"), + startup_plugin_specs: str = Field( + default="", + validation_alias=AliasChoices("ERGON_STARTUP_PLUGINS"), ) @property @@ -70,11 +70,11 @@ def runs_dir(self) -> Path: return self.data_dir / "runs" @property - def smoke_fixtures_enabled(self) -> bool: - return ( - self.enable_smoke_fixtures - if self.enable_smoke_fixtures is not None - else self.enable_test_harness + def startup_plugins(self) -> tuple[str, ...]: + return tuple( + spec.strip() + for spec in self.startup_plugin_specs.split(",") + if spec.strip() ) def missing_values(self, names: list[str]) -> list[str]: diff --git a/ergon_core/ergon_core/test_support/sandbox/__init__.py b/ergon_core/ergon_core/test_support/sandbox/__init__.py new file mode 100644 index 00000000..0c21d80c --- /dev/null +++ b/ergon_core/ergon_core/test_support/sandbox/__init__.py @@ -0,0 +1,13 @@ +"""Test-support sandbox doubles.""" + +from ergon_core.test_support.sandbox.sentinel import is_stub_sandbox_id + +__all__ = ["StubSandboxManager", "is_stub_sandbox_id"] + + +def __getattr__(name: str) -> object: + if name == "StubSandboxManager": + from ergon_core.test_support.sandbox.stub_manager import StubSandboxManager + + return StubSandboxManager + raise AttributeError(name) diff --git a/ergon_core/ergon_core/test_support/sandbox/sentinel.py b/ergon_core/ergon_core/test_support/sandbox/sentinel.py new file mode 100644 index 00000000..372ed856 --- /dev/null +++ b/ergon_core/ergon_core/test_support/sandbox/sentinel.py @@ -0,0 +1,7 @@ +"""Sentinel helpers for test-support sandbox IDs.""" + +STUB_SANDBOX_PREFIX = "stub-sandbox-" + + +def is_stub_sandbox_id(sandbox_id: object) -> bool: + return isinstance(sandbox_id, str) and sandbox_id.startswith(STUB_SANDBOX_PREFIX) diff --git a/ergon_core/ergon_core/test_support/sandbox/stub_manager.py b/ergon_core/ergon_core/test_support/sandbox/stub_manager.py new file mode 100644 index 00000000..2a04416b --- /dev/null +++ b/ergon_core/ergon_core/test_support/sandbox/stub_manager.py @@ -0,0 +1,54 @@ +"""Sandbox manager test double.""" + +from __future__ import annotations + +import logging +from uuid import UUID + +from ergon_core.core.providers.sandbox.manager import AsyncSandbox, BaseSandboxManager +from ergon_core.test_support.sandbox.sentinel import STUB_SANDBOX_PREFIX + +logger = logging.getLogger(__name__) + + +class _StubSandbox: + def __init__(self, sandbox_id: str) -> None: + self.sandbox_id = sandbox_id + + async def kill(self) -> None: + return None + + +class StubSandboxManager(BaseSandboxManager): + """No-op sandbox manager for tests.""" + + async def create( + self, + sandbox_key: UUID, + run_id: UUID, + timeout_minutes: int = 30, + envs: dict[str, str] | None = None, + display_task_id: UUID | None = None, + ) -> str: + stub_id = f"{STUB_SANDBOX_PREFIX}{sandbox_key}" + logger.info("Returning test stub sandbox id %s for task %s", stub_id, sandbox_key) + self._ensure_registries(sandbox_key) + self._sandboxes[sandbox_key] = _StubSandbox(stub_id) # type: ignore[assignment] + self._run_ids[sandbox_key] = run_id + self._display_task_ids[sandbox_key] = display_task_id or sandbox_key + self._sandbox_manager_classes[sandbox_key] = type(self) + return stub_id + + async def _install_dependencies(self, sandbox: AsyncSandbox, task_id: UUID) -> None: + return None + + async def terminate(self, task_id: UUID, reason: str = "completed") -> None: + self._sandboxes.pop(task_id, None) + self._file_registries.pop(task_id, None) + self._created_files_registry.pop(task_id, None) + self._run_ids.pop(task_id, None) + self._display_task_ids.pop(task_id, None) + self._sandbox_manager_classes.pop(task_id, None) + + async def reset_timeout(self, task_id: UUID, timeout_minutes: int = 30) -> bool: + return True diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py b/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py index f60f5b68..275204b8 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py @@ -29,18 +29,20 @@ ) from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager from ergon_core.test_support.smoke_fixtures.workers.minif2f_smoke import ( + MiniF2FFailingLeafWorker, + MiniF2FSadPathSmokeWorker, MiniF2FSmokeLeafWorker, MiniF2FSmokeWorker, ) from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke import ( - ResearchRubricsSmokeLeafWorker, - ResearchRubricsSmokeWorker, -) -from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke_sadpath import ( ResearchRubricsFailingLeafWorker, ResearchRubricsSadPathSmokeWorker, + ResearchRubricsSmokeLeafWorker, + ResearchRubricsSmokeWorker, ) from ergon_core.test_support.smoke_fixtures.workers.swebench_smoke import ( + SweBenchFailingLeafWorker, + SweBenchSadPathSmokeWorker, SweBenchSmokeLeafWorker, SweBenchSmokeWorker, ) @@ -79,12 +81,16 @@ def register_smoke_fixtures() -> None: WORKERS[ResearchRubricsSadPathSmokeWorker.type_slug] = ResearchRubricsSadPathSmokeWorker WORKERS[ResearchRubricsFailingLeafWorker.type_slug] = ResearchRubricsFailingLeafWorker - # MiniF2F happy-path + # MiniF2F happy + sad-path WORKERS[MiniF2FSmokeWorker.type_slug] = MiniF2FSmokeWorker WORKERS[MiniF2FSmokeLeafWorker.type_slug] = MiniF2FSmokeLeafWorker + WORKERS[MiniF2FSadPathSmokeWorker.type_slug] = MiniF2FSadPathSmokeWorker + WORKERS[MiniF2FFailingLeafWorker.type_slug] = MiniF2FFailingLeafWorker EVALUATORS[MiniF2FSmokeRubric.type_slug] = MiniF2FSmokeRubric - # SWE-Bench Verified happy-path + # SWE-Bench Verified happy + sad-path WORKERS[SweBenchSmokeWorker.type_slug] = SweBenchSmokeWorker WORKERS[SweBenchSmokeLeafWorker.type_slug] = SweBenchSmokeLeafWorker + WORKERS[SweBenchSadPathSmokeWorker.type_slug] = SweBenchSadPathSmokeWorker + WORKERS[SweBenchFailingLeafWorker.type_slug] = SweBenchFailingLeafWorker EVALUATORS[SweBenchSmokeRubric.type_slug] = SweBenchSmokeRubric diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py new file mode 100644 index 00000000..a09e9bf6 --- /dev/null +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py @@ -0,0 +1,83 @@ +"""Shared smoke sad-path helpers. + +The canonical sad path routes ``l_2`` to a failing leaf. ``l_3`` depends +on ``l_2``, so runtime propagation should leave ``l_3`` blocked and never +started while independent branches continue normally. +""" + +from typing import ClassVar + +from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] + +from ergon_core.api import WorkerContext +from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug +from ergon_core.core.runtime.services.task_management_dto import SubtaskSpec +from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult + + +class AlwaysFailSubworker: + """Writes partial work and runs a probe before returning failure.""" + + async def work(self, node_id: str, sandbox: AsyncSandbox) -> SubworkerResult: + partial_path = f"/workspace/final_output/partial_{node_id}.md" + await sandbox.files.write( + partial_path, + ( + f"# Partial work {node_id}\n\n" + "This content was written before a deliberate failure. If smoke " + "sees this as a RunResource row, partial serialization works.\n" + ), + ) + + pre_check = await sandbox.commands.run( + f"wc -l {partial_path}", + timeout=5, + ) + if pre_check.exit_code != 0: + raise RuntimeError( + "AlwaysFailSubworker: precondition failed - expected wc to " + f"succeed but got exit={pre_check.exit_code}. Sad-path design " + "assumes partial work completes cleanly before the failure result.", + ) + + return SubworkerResult( + file_path=partial_path, + probe_stdout=( + f"SmokeSadPathError: deliberate failure of {node_id} after " + f"writing {partial_path} and running probe " + f"(exit={pre_check.exit_code}). Smoke asserts the partial file + " + "probe WAL survive." + ), + probe_exit_code=1, + ) + + +class SadPathSmokeWorkerMixin: + """Route ``l_2`` to a failing leaf without changing smoke topology.""" + + FAILING_SLUGS: ClassVar[frozenset[str]] = frozenset({"l_2"}) + FAILING_LEAF_SLUG: ClassVar[str] + leaf_slug: ClassVar[str] + + def _spec_for(self, slug, deps, desc): + leaf_slug = self.FAILING_LEAF_SLUG if slug in self.FAILING_SLUGS else self.leaf_slug + return SubtaskSpec( + task_slug=TaskSlug(slug), + description=desc, + assigned_worker_slug=AssignedWorkerSlug(leaf_slug), + depends_on=[TaskSlug(d) for d in deps], + ) + + +class FailingSmokeLeafMixin: + """Suppress happy-path completion messages for deliberate failing leaves.""" + + async def _send_completion_message( + self, + context: WorkerContext, + result: SubworkerResult, + ) -> None: + return None + + +__all__ = ["AlwaysFailSubworker", "FailingSmokeLeafMixin", "SadPathSmokeWorkerMixin"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py index d2e43cba..f6beaed2 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py @@ -19,6 +19,7 @@ from ergon_core.api import BenchmarkTask, Worker, WorkerContext from ergon_core.api.generation import GenerationTurn, TextPart +from ergon_core.api.results import WorkerOutput from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.types import ( @@ -61,6 +62,10 @@ class SmokeWorkerBase(Worker): # (see tests/e2e/_asserts.py ``_assert_run_turn_counts``). PARENT_TURN_COUNT: ClassVar[int] = 3 + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._last_child_statuses: dict[str, str] = {} + @final async def execute( self, @@ -138,9 +143,24 @@ async def execute( parent_node_id=context.node_id, ) if children and all(c.status in _CHILD_WAIT_TERMINAL_STATUSES for c in children): + self._last_child_statuses = {c.name: c.status for c in children} break await asyncio.sleep(2) + def get_output(self, context: WorkerContext) -> WorkerOutput: + non_completed = { + slug: status + for slug, status in self._last_child_statuses.items() + if status != "completed" + } + if non_completed: + return WorkerOutput( + output=f"child tasks did not all complete: {non_completed}", + success=False, + metadata={"child_statuses": self._last_child_statuses}, + ) + return super().get_output(context) + def _spec_for( self, slug: str, diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py index 05ad2ed4..8f661f69 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py @@ -12,6 +12,11 @@ from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker +from ergon_core.test_support.smoke_fixtures.smoke_base.sadpath import ( + AlwaysFailSubworker, + FailingSmokeLeafMixin, + SadPathSmokeWorkerMixin, +) from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase @@ -63,7 +68,24 @@ class MiniF2FSmokeLeafWorker(BaseSmokeLeafWorker): subworker_cls = MiniF2FSubworker +class MiniF2FFailingLeafWorker(FailingSmokeLeafMixin, BaseSmokeLeafWorker): + """Registered leaf that fails after partial work.""" + + type_slug = "minif2f-smoke-leaf-failing" + subworker_cls = AlwaysFailSubworker + + +class MiniF2FSadPathSmokeWorker(SadPathSmokeWorkerMixin, SmokeWorkerBase): + """Parent that routes ``l_2`` to the failing leaf.""" + + type_slug = "minif2f-sadpath-smoke-worker" + leaf_slug = "minif2f-smoke-leaf" + FAILING_LEAF_SLUG = "minif2f-smoke-leaf-failing" + + __all__ = [ + "MiniF2FFailingLeafWorker", + "MiniF2FSadPathSmokeWorker", "MiniF2FSmokeLeafWorker", "MiniF2FSmokeWorker", "MiniF2FSubworker", diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py index 092ed281..11cce463 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py @@ -19,6 +19,11 @@ from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker +from ergon_core.test_support.smoke_fixtures.smoke_base.sadpath import ( + AlwaysFailSubworker, + FailingSmokeLeafMixin, + SadPathSmokeWorkerMixin, +) from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase @@ -69,7 +74,24 @@ class ResearchRubricsSmokeLeafWorker(BaseSmokeLeafWorker): subworker_cls = ResearchRubricsSubworker +class ResearchRubricsFailingLeafWorker(FailingSmokeLeafMixin, BaseSmokeLeafWorker): + """Registered leaf that fails after partial work.""" + + type_slug = "researchrubrics-smoke-leaf-failing" + subworker_cls = AlwaysFailSubworker + + +class ResearchRubricsSadPathSmokeWorker(SadPathSmokeWorkerMixin, SmokeWorkerBase): + """Parent that routes ``l_2`` to the failing leaf.""" + + type_slug = "researchrubrics-sadpath-smoke-worker" + leaf_slug = "researchrubrics-smoke-leaf" + FAILING_LEAF_SLUG = "researchrubrics-smoke-leaf-failing" + + __all__ = [ + "ResearchRubricsFailingLeafWorker", + "ResearchRubricsSadPathSmokeWorker", "ResearchRubricsSmokeLeafWorker", "ResearchRubricsSmokeWorker", "ResearchRubricsSubworker", diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py index fda081bf..9ccc38f4 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py @@ -1,123 +1,10 @@ -"""ResearchRubrics score-zero sad-path fixture. +"""Compatibility imports for the ResearchRubrics sad-path fixture.""" -Used in researchrubrics cohort slot 3 (see -``docs/superpowers/plans/test-refactor/00-program.md §3.2``). Routes -``l_2`` to a failing leaf that DOES real work (file write + sandbox -command) BEFORE raising; the rest of the 9-subtask topology is -unchanged. - -Driver asserts partial artifact + pre-fail WAL entry persist, all leaves -complete, and the run evaluation scores zero because l_2 reports a failed -probe result. -""" - -from typing import ClassVar - -from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] -from ergon_core.api import WorkerContext - -from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug -from ergon_core.core.runtime.services.task_management_dto import SubtaskSpec - -from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker -from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult -from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase - - -class AlwaysFailSubworker: - """Does TWO units of real work, then returns a failing probe result. - - Proves the partial-work-persists-on-failure path. When the leaf - fails after partial work: - - 1. The partial file we wrote to ``/workspace/final_output/`` still - becomes a ``RunResource`` row (the runtime's persist step runs - regardless of worker exit outcome). - 2. The sandbox command we already ran still emits a - ``sandbox_command`` event / WAL entry (the command path writes - synchronously, before our raise). - 3. The leaf's task row still completes because worker execution itself - completed and output persistence should remain exercised. - 4. The reused smoke criterion scores the run zero after reading the - failed probe result. - """ - - async def work(self, node_id: str, sandbox: AsyncSandbox) -> SubworkerResult: - # Action 1: write partial artifact — must land as a RunResource. - partial_path = f"/workspace/final_output/partial_{node_id}.md" - await sandbox.files.write( - partial_path, - ( - f"# Partial work {node_id}\n\n" - "This content was written before a deliberate failure. If smoke " - "sees this as a RunResource row, partial serialization works.\n" - ), - ) - - # Action 2: run a sandbox command — must emit sandbox_command WAL. - pre_check = await sandbox.commands.run( - f"wc -l {partial_path}", - timeout=5, - ) - if pre_check.exit_code != 0: - raise RuntimeError( - "AlwaysFailSubworker: precondition failed — expected wc to " - f"succeed but got exit={pre_check.exit_code}. Sad-path design " - "assumes partial work completes cleanly before the raise.", - ) - - # Action 3: deliberate failure via WorkerOutput.success=False. This - # exercises the failed-task path without bypassing output persistence. - return SubworkerResult( - file_path=partial_path, - probe_stdout=( - f"SmokeSadPathError: deliberate failure of {node_id} after " - f"writing {partial_path} and running probe " - f"(exit={pre_check.exit_code}). Smoke asserts the partial file + " - "probe WAL survive." - ), - probe_exit_code=1, - ) - - -class ResearchRubricsFailingLeafWorker(BaseSmokeLeafWorker): - """Registered leaf that always fails after 2 units of real work.""" - - type_slug = "researchrubrics-smoke-leaf-failing" - subworker_cls = AlwaysFailSubworker - - async def _send_completion_message( - self, - context: WorkerContext, - result: SubworkerResult, - ) -> None: - """Preserve sad-path invariant: failed l_2 does not report completion.""" - return None - - -class ResearchRubricsSadPathSmokeWorker(SmokeWorkerBase): - """Parent that routes ``l_2`` to the failing leaf; everything else - routes to the normal leaf. - - Topology stays identical (still 9 subtasks, same deps); only the leaf - binding for ``l_2`` differs. ``execute`` is still ``@final``; the - hook is ``_spec_for``. - """ - - type_slug = "researchrubrics-sadpath-smoke-worker" - leaf_slug = "researchrubrics-smoke-leaf" # default for everything EXCEPT l_2 - - FAILING_SLUGS: ClassVar[frozenset[str]] = frozenset({"l_2"}) - FAILING_LEAF_SLUG: ClassVar[str] = "researchrubrics-smoke-leaf-failing" - - def _spec_for(self, slug, deps, desc): - leaf_slug = self.FAILING_LEAF_SLUG if slug in self.FAILING_SLUGS else self.leaf_slug - return SubtaskSpec( - task_slug=TaskSlug(slug), - description=desc, - assigned_worker_slug=AssignedWorkerSlug(leaf_slug), - depends_on=[TaskSlug(d) for d in deps], - ) +from ergon_core.test_support.smoke_fixtures.smoke_base.sadpath import AlwaysFailSubworker +from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke import ( + ResearchRubricsFailingLeafWorker, + ResearchRubricsSadPathSmokeWorker, +) __all__ = [ diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py index cd3e7c04..4bad9cf9 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py @@ -10,6 +10,11 @@ from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker +from ergon_core.test_support.smoke_fixtures.smoke_base.sadpath import ( + AlwaysFailSubworker, + FailingSmokeLeafMixin, + SadPathSmokeWorkerMixin, +) from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase @@ -61,7 +66,24 @@ class SweBenchSmokeLeafWorker(BaseSmokeLeafWorker): subworker_cls = SweBenchSubworker +class SweBenchFailingLeafWorker(FailingSmokeLeafMixin, BaseSmokeLeafWorker): + """Registered leaf that fails after partial work.""" + + type_slug = "swebench-smoke-leaf-failing" + subworker_cls = AlwaysFailSubworker + + +class SweBenchSadPathSmokeWorker(SadPathSmokeWorkerMixin, SmokeWorkerBase): + """Parent that routes ``l_2`` to the failing leaf.""" + + type_slug = "swebench-sadpath-smoke-worker" + leaf_slug = "swebench-smoke-leaf" + FAILING_LEAF_SLUG = "swebench-smoke-leaf-failing" + + __all__ = [ + "SweBenchFailingLeafWorker", + "SweBenchSadPathSmokeWorker", "SweBenchSmokeLeafWorker", "SweBenchSmokeWorker", "SweBenchSubworker", diff --git a/ergon_core/migrations/versions/0a1b2c3d4e5f_add_thread_summary.py b/ergon_core/migrations/versions/0a1b2c3d4e5f_add_thread_summary.py new file mode 100644 index 00000000..a888473b --- /dev/null +++ b/ergon_core/migrations/versions/0a1b2c3d4e5f_add_thread_summary.py @@ -0,0 +1,26 @@ +"""add_thread_summary + +Revision ID: 0a1b2c3d4e5f +Revises: f6a7b8c9d0e1 +Create Date: 2026-04-26 19:45:00.000000 + +Add an optional human-readable summary for communication threads. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0a1b2c3d4e5f" +down_revision: Union[str, None] = "f6a7b8c9d0e1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("threads", sa.Column("summary", sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("threads", "summary") diff --git a/ergon_paper_overleaf_edit/checklist.tex b/ergon_paper_overleaf_edit/checklist.tex deleted file mode 100644 index b0bbc72e..00000000 --- a/ergon_paper_overleaf_edit/checklist.tex +++ /dev/null @@ -1,224 +0,0 @@ -\section*{NeurIPS Paper Checklist} - -\begin{enumerate} - -\item {\bf Claims} - \item[] Question: Do the main claims made in the abstract and introduction accurately reflect the paper's contributions and scope? - \item[] Answer: \answerYes{} - \item[] Justification: The abstract and \S\ref{sec:intro} claim (i) that current agent research publishes reported numbers without rollouts, producing cross-community fragmentation and cross-harness variance, and (ii) that rollout cards plus drops manifests address both problems; these are substantiated by the 50-repo survey (Appendix~\ref{app:survey}), the 37-pair variance catalogue (Appendix~\ref{app:variance-catalogue}), the format specification in \S\ref{sec:system} and Appendix~\ref{app:system}, and the two experiments of \S\ref{sec:validation}. Scope limits (proof-of-concept, two RQ1 pairings, one RQ2 reconciliation on a single benchmark, two public submissions) are stated explicitly in \S\ref{sec:validation:questions} and revisited in the Limitations paragraph of \S\ref{sec:discussion}. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the abstract and introduction do not include the claims made in the paper. - \item The abstract and/or introduction should clearly state the claims made, including the contributions made in the paper and important assumptions and limitations. A \answerNo{} or \answerNA{} answer to this question will not be perceived well by the reviewers. - \item The claims made should match theoretical and experimental results, and reflect how much the results can be expected to generalize to other settings. - \item It is fine to include aspirational goals as motivation as long as it is clear that these goals are not attained by the paper. - \end{itemize} - -\item {\bf Limitations} - \item[] Question: Does the paper discuss the limitations of the work performed by the authors? - \item[] Answer: \answerYes{} - \item[] Justification: \S\ref{sec:discussion} contains an explicit Limitations paragraph that enumerates: representative coverage of five communities from a long tail, 50 repositories from a much larger population, 37 variance pairs from the documented-comparison subset; citation-based (not reproduced) training-side transform-variance evidence; single-analyst cross-community reanalysis (one target community per task family); and single-benchmark reconciliation (SWE-bench Verified, two published submissions). The paper's claims are scoped to these bounds. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper has no limitation while the answer \answerNo{} means that the paper has limitations, but those are not discussed in the paper. - \item The authors are encouraged to create a separate ``Limitations'' section in their paper. - \item The paper should point out any strong assumptions and how robust the results are to violations of these assumptions (e.g., independence assumptions, noiseless settings, model well-specification, asymptotic approximations only holding locally). The authors should reflect on how these assumptions might be violated in practice and what the implications would be. - \item The authors should reflect on the scope of the claims made, e.g., if the approach was only tested on a few datasets or with a few runs. In general, empirical results often depend on implicit assumptions, which should be articulated. - \item The authors should reflect on the factors that influence the performance of the approach. For example, a facial recognition algorithm may perform poorly when image resolution is low or images are taken in low lighting. Or a speech-to-text system might not be used reliably to provide closed captions for online lectures because it fails to handle technical jargon. - \item The authors should discuss the computational efficiency of the proposed algorithms and how they scale with dataset size. - \item If applicable, the authors should discuss possible limitations of their approach to address problems of privacy and fairness. - \item While the authors might fear that complete honesty about limitations might be used by reviewers as grounds for rejection, a worse outcome might be that reviewers discover limitations that aren't acknowledged in the paper. The authors should use their best judgment and recognize that individual actions in favor of transparency play an important role in developing norms that preserve the integrity of the community. Reviewers will be specifically instructed to not penalize honesty concerning limitations. - \end{itemize} - -\item {\bf Theory assumptions and proofs} - \item[] Question: For each theoretical result, does the paper provide the full set of assumptions and a complete (and correct) proof? - \item[] Answer: \answerNA{} - \item[] Justification: The paper does not state theorems or prove formal results. Projection operators in Appendix~\ref{app:projections} are defined constructively as pseudocode specifications with accompanying drops manifests; preservation claims are enumerative (Table~\ref{tab:preservation}) and follow directly from the projection definitions, not from proof obligations. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper does not include theoretical results. - \item All the theorems, formulas, and proofs in the paper should be numbered and cross-referenced. - \item All assumptions should be clearly stated or referenced in the statement of any theorems. - \item The proofs can either appear in the main paper or the supplemental material, but if they appear in the supplemental material, the authors are encouraged to provide a short proof sketch to provide intuition. - \item Inversely, any informal proof provided in the core of the paper should be complemented by formal proofs provided in appendix or supplemental material. - \item Theorems and Lemmas that the proof relies upon should be properly referenced. - \end{itemize} - - \item {\bf Experimental result reproducibility} - \item[] Question: Does the paper fully disclose all the information needed to reproduce the main experimental results of the paper to the extent that it affects the main claims and/or conclusions of the paper (regardless of whether the code and data are provided or not)? - \item[] Answer: \answerYes{} - \item[] Justification: \S\ref{sec:validation:setup} specifies the three task families, agent backbone, task-specific tools, and grading procedures; Appendix~\ref{app:setup} consolidates the per-benchmark action space (\S\ref{app:actions}), benchmark selection (\S\ref{app:benchmarks}), flexible-agent details (\S\ref{app:fivescaffolds}), and cross-harness reconciliation methodology (\S\ref{app:reconciliation}); Appendix~\ref{app:system} specifies the rollout-card format bit-for-bit. The reference implementation is open-source; all reported numbers are either deterministic functions of published rollouts (RQ2) or derivable from the released rollout cards (RQ1). - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper does not include experiments. - \item If the paper includes experiments, a \answerNo{} answer to this question will not be perceived well by the reviewers: Making the paper reproducible is important, regardless of whether the code and data are provided or not. - \item If the contribution is a dataset and\slash or model, the authors should describe the steps taken to make their results reproducible or verifiable. - \item Depending on the contribution, reproducibility can be accomplished in various ways. For example, if the contribution is a novel architecture, describing the architecture fully might suffice, or if the contribution is a specific model and empirical evaluation, it may be necessary to either make it possible for others to replicate the model with the same dataset, or provide access to the model. In general. releasing code and data is often one good way to accomplish this, but reproducibility can also be provided via detailed instructions for how to replicate the results, access to a hosted model (e.g., in the case of a large language model), releasing of a model checkpoint, or other means that are appropriate to the research performed. - \item While NeurIPS does not require releasing code, the conference does require all submissions to provide some reasonable avenue for reproducibility, which may depend on the nature of the contribution. For example - \begin{enumerate} - \item If the contribution is primarily a new algorithm, the paper should make it clear how to reproduce that algorithm. - \item If the contribution is primarily a new model architecture, the paper should describe the architecture clearly and fully. - \item If the contribution is a new model (e.g., a large language model), then there should either be a way to access this model for reproducing the results or a way to reproduce the model (e.g., with an open-source dataset or instructions for how to construct the dataset). - \item We recognize that reproducibility may be tricky in some cases, in which case authors are welcome to describe the particular way they provide for reproducibility. In the case of closed-source models, it may be that access to the model is limited in some way (e.g., to registered users), but it should be possible for other researchers to have some path to reproducing or verifying the results. - \end{enumerate} - \end{itemize} - - -\item {\bf Open access to data and code} - \item[] Question: Does the paper provide open access to the data and code, with sufficient instructions to faithfully reproduce the main experimental results, as described in supplemental material? - \item[] Answer: \answerYes{} - \item[] Justification: Ergon (the recording substrate and reference implementation) is released as open source with README, architecture documentation, and benchmark harness; an anonymised artefact bundle accompanies this submission. The SWE-agent and Agentless SWE-bench Verified submissions used in RQ2 are already public in the \texttt{swe-bench-submissions} S3 bucket. Rollout cards for the MiniF2F and Research Rubrics experiments will be released alongside the paper, as will the cross-harness reconciliation pipeline and convention specification (\S\ref{app:reconciliation}). - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that paper does not include experiments requiring code. - \item Please see the NeurIPS code and data submission guidelines (\url{https://neurips.cc/public/guides/CodeSubmissionPolicy}) for more details. - \item While we encourage the release of code and data, we understand that this might not be possible, so \answerNo{} is an acceptable answer. Papers cannot be rejected simply for not including code, unless this is central to the contribution (e.g., for a new open-source benchmark). - \item The instructions should contain the exact command and environment needed to run to reproduce the results. See the NeurIPS code and data submission guidelines (\url{https://neurips.cc/public/guides/CodeSubmissionPolicy}) for more details. - \item The authors should provide instructions on data access and preparation, including how to access the raw data, preprocessed data, intermediate data, and generated data, etc. - \item The authors should provide scripts to reproduce all experimental results for the new proposed method and baselines. If only a subset of experiments are reproducible, they should state which ones are omitted from the script and why. - \item At submission time, to preserve anonymity, the authors should release anonymized versions (if applicable). - \item Providing as much information as possible in supplemental material (appended to the paper) is recommended, but including URLs to data and code is permitted. - \end{itemize} - - -\item {\bf Experimental setting/details} - \item[] Question: Does the paper specify all the training and test details (e.g., data splits, hyperparameters, how they were chosen, type of optimizer) necessary to understand the results? - \item[] Answer: \answerYes{} - \item[] Justification: The paper reports no training; all rollouts are inference-only against an API-served model. \S\ref{sec:validation:setup} specifies the model backbone, per-task-family turn cap, action space, and grading procedure. Appendix~\ref{app:setup} specifies the benchmark subsets used (\S\ref{app:benchmarks}), the full agent tool inventory and system-prompt structure (\S\ref{app:fivescaffolds}), and the convention choices in the SWE-bench reconciliation (\S\ref{app:reconciliation}). - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper does not include experiments. - \item The experimental setting should be presented in the core of the paper to a level of detail that is necessary to appreciate the results and make sense of them. - \item The full details can be provided either with the code, in appendix, or as supplemental material. - \end{itemize} - -\item {\bf Experiment statistical significance} - \item[] Question: Does the paper report error bars suitably and correctly defined or other appropriate information about the statistical significance of the experiments? - \item[] Answer: \answerYes{} - \item[] Justification: The RQ2 cross-harness reconciliation is a deterministic re-grading of already-released trajectories, so no stochastic variation enters the pipeline; we report exact denominators and decomposition deltas rather than error bars. For the RQ1 rollouts (MiniF2F and Research Rubrics), we report Wilson-score 95\% confidence intervals for the rate statistics (abandonment ratios, role-differentiation fractions) and specify the rollout count driving each interval; the cross-community analyses are qualitative proof-of-concept claims at the single-seed level, which is stated explicitly. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper does not include experiments. - \item The authors should answer \answerYes{} if the results are accompanied by error bars, confidence intervals, or statistical significance tests, at least for the experiments that support the main claims of the paper. - \item The factors of variability that the error bars are capturing should be clearly stated (for example, train/test split, initialization, random drawing of some parameter, or overall run with given experimental conditions). - \item The method for calculating the error bars should be explained (closed form formula, call to a library function, bootstrap, etc.) - \item The assumptions made should be given (e.g., Normally distributed errors). - \item It should be clear whether the error bar is the standard deviation or the standard error of the mean. - \item It is OK to report 1-sigma error bars, but one should state it. The authors should preferably report a 2-sigma error bar than state that they have a 96\% CI, if the hypothesis of Normality of errors is not verified. - \item For asymmetric distributions, the authors should be careful not to show in tables or figures symmetric error bars that would yield results that are out of range (e.g., negative error rates). - \item If error bars are reported in tables or plots, the authors should explain in the text how they were calculated and reference the corresponding figures or tables in the text. - \end{itemize} - -\item {\bf Experiments compute resources} - \item[] Question: For each experiment, does the paper provide sufficient information on the computer resources (type of compute workers, memory, time of execution) needed to reproduce the experiments? - \item[] Answer: \answerYes{} - \item[] Justification: No model training is performed. Agent rollouts for RQ1 are inference-only calls to an API-hosted LLM backbone; rollout counts and per-rollout token budgets are reported in Appendix~\ref{app:fivescaffolds}. The RQ2 reconciliation pipeline (ingestion of SWE-agent and Agentless submissions, re-grading under two conventions) runs on a single developer workstation in minutes; compute requirements are stated in \S\ref{app:reconciliation}. No GPU is required for any reported experiment. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper does not include experiments. - \item The paper should indicate the type of compute workers CPU or GPU, internal cluster, or cloud provider, including relevant memory and storage. - \item The paper should provide the amount of compute required for each of the individual experimental runs as well as estimate the total compute. - \item The paper should disclose whether the full research project required more compute than the experiments reported in the paper (e.g., preliminary or failed experiments that didn't make it into the paper). - \end{itemize} - -\item {\bf Code of ethics} - \item[] Question: Does the research conducted in the paper conform, in every respect, with the NeurIPS Code of Ethics \url{https://neurips.cc/public/EthicsGuidelines}? - \item[] Answer: \answerYes{} - \item[] Justification: The paper proposes a publication format and reference implementation for agent research. It involves no human subjects and no data scraping beyond publicly-released benchmark submissions whose licenses permit re-analysis. The reference implementation is released as open source. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the authors have not reviewed the NeurIPS Code of Ethics. - \item If the authors answer \answerNo, they should explain the special circumstances that require a deviation from the Code of Ethics. - \item The authors should make sure to preserve anonymity (e.g., if there is a special consideration due to laws or regulations in their jurisdiction). - \end{itemize} - - -\item {\bf Broader impacts} - \item[] Question: Does the paper discuss both potential positive societal impacts and negative societal impacts of the work performed? - \item[] Answer: \answerYes{} - \item[] Justification: Positive impacts --- improved auditability of agent-research claims, cross-harness reconciliation, and legibility of methodology differences --- are discussed in \S\ref{sec:discussion} (``Publication cost and adoption'' and ``Detection, not resolution''). Negative impacts are minimal: a publication format itself cannot be weaponised, and the rollouts released alongside benchmarks inherit the data-handling norms of their source benchmarks (MiniF2F, SWE-bench Verified, Research Rubrics). No new scraped or high-risk data is introduced. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that there is no societal impact of the work performed. - \item If the authors answer \answerNA{} or \answerNo, they should explain why their work has no societal impact or why the paper does not address societal impact. - \item Examples of negative societal impacts include potential malicious or unintended uses (e.g., disinformation, generating fake profiles, surveillance), fairness considerations (e.g., deployment of technologies that could make decisions that unfairly impact specific groups), privacy considerations, and security considerations. - \item The conference expects that many papers will be foundational research and not tied to particular applications, let alone deployments. However, if there is a direct path to any negative applications, the authors should point it out. For example, it is legitimate to point out that an improvement in the quality of generative models could be used to generate Deepfakes for disinformation. On the other hand, it is not needed to point out that a generic algorithm for optimizing neural networks could enable people to train models that generate Deepfakes faster. - \item The authors should consider possible harms that could arise when the technology is being used as intended and functioning correctly, harms that could arise when the technology is being used as intended but gives incorrect results, and harms following from (intentional or unintentional) misuse of the technology. - \item If there are negative societal impacts, the authors could also discuss possible mitigation strategies (e.g., gated release of models, providing defenses in addition to attacks, mechanisms for monitoring misuse, mechanisms to monitor how a system learns from feedback over time, improving the efficiency and accessibility of ML). - \end{itemize} - -\item {\bf Safeguards} - \item[] Question: Does the paper describe safeguards that have been put in place for responsible release of data or models that have a high risk for misuse (e.g., pre-trained language models, image generators, or scraped datasets)? - \item[] Answer: \answerNA{} - \item[] Justification: The paper releases a format specification (rollout cards) and a reference recording substrate (Ergon). Neither is a pre-trained model, generative system, or scraped dataset. The rollouts released alongside are bounded by the source benchmarks' existing release policies. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper poses no such risks. - \item Released models that have a high risk for misuse or dual-use should be released with necessary safeguards to allow for controlled use of the model, for example by requiring that users adhere to usage guidelines or restrictions to access the model or implementing safety filters. - \item Datasets that have been scraped from the Internet could pose safety risks. The authors should describe how they avoided releasing unsafe images. - \item We recognize that providing effective safeguards is challenging, and many papers do not require this, but we encourage authors to take this into account and make a best faith effort. - \end{itemize} - -\item {\bf Licenses for existing assets} - \item[] Question: Are the creators or original owners of assets (e.g., code, data, models), used in the paper, properly credited and are the license and terms of use explicitly mentioned and properly respected? - \item[] Answer: \answerYes{} - \item[] Justification: MiniF2F~\citep{zheng2022minif2f}, SWE-bench Verified, SWE-agent and Agentless submissions, the Research Rubrics benchmark, and the LLM API backbones used for rollout generation and rubric grading are all cited in \S\ref{sec:validation} and \S\ref{sec:related}. Asset licenses and version identifiers are catalogued in Appendix~\ref{app:benchmarks}, including the specific SWE-bench submission commits and the API model versions used. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper does not use existing assets. - \item The authors should cite the original paper that produced the code package or dataset. - \item The authors should state which version of the asset is used and, if possible, include a URL. - \item The name of the license (e.g., CC-BY 4.0) should be included for each asset. - \item For scraped data from a particular source (e.g., website), the copyright and terms of service of that source should be provided. - \item If assets are released, the license, copyright information, and terms of use in the package should be provided. For popular datasets, \url{paperswithcode.com/datasets} has curated licenses for some datasets. Their licensing guide can help determine the license of a dataset. - \item For existing datasets that are re-packaged, both the original license and the license of the derived asset (if it has changed) should be provided. - \item If this information is not available online, the authors are encouraged to reach out to the asset's creators. - \end{itemize} - -\item {\bf New assets} - \item[] Question: Are new assets introduced in the paper well documented and is the documentation provided alongside the assets? - \item[] Answer: \answerYes{} - \item[] Justification: Three new assets accompany the paper: (i) the rollout-card format specification (Appendix~\ref{app:system}); (ii) Ergon, the reference implementation, with a README, architecture documentation, and RFC stream for pending integrations (Appendix~\ref{app:integrations}); (iii) ingestion adapters for SWE-agent and Agentless submissions with accompanying drops manifests (\S\ref{app:reconciliation}). All three are documented in-repository and anonymised for submission. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper does not release new assets. - \item Researchers should communicate the details of the dataset\slash code\slash model as part of their submissions via structured templates. This includes details about training, license, limitations, etc. - \item The paper should discuss whether and how consent was obtained from people whose asset is used. - \item At submission time, remember to anonymize your assets (if applicable). You can either create an anonymized URL or include an anonymized zip file. - \end{itemize} - -\item {\bf Crowdsourcing and research with human subjects} - \item[] Question: For crowdsourcing experiments and research with human subjects, does the paper include the full text of instructions given to participants and screenshots, if applicable, as well as details about compensation (if any)? - \item[] Answer: \answerNA{} - \item[] Justification: The paper involves no crowdsourcing and no human subjects. The Research Rubrics LLM-based evaluator is an API grading transform, not a human participant; the 50-repo survey and the 37-pair variance catalogue are authored by the paper's authors against public artefacts. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper does not involve crowdsourcing nor research with human subjects. - \item Including this information in the supplemental material is fine, but if the main contribution of the paper involves human subjects, then as much detail as possible should be included in the main paper. - \item According to the NeurIPS Code of Ethics, workers involved in data collection, curation, or other labor should be paid at least the minimum wage in the country of the data collector. - \end{itemize} - -\item {\bf Institutional review board (IRB) approvals or equivalent for research with human subjects} - \item[] Question: Does the paper describe potential risks incurred by study participants, whether such risks were disclosed to the subjects, and whether Institutional Review Board (IRB) approvals (or an equivalent approval/review based on the requirements of your country or institution) were obtained? - \item[] Answer: \answerNA{} - \item[] Justification: No human subjects research is performed. No IRB review is applicable. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the paper does not involve crowdsourcing nor research with human subjects. - \item Depending on the country in which research is conducted, IRB approval (or equivalent) may be required for any human subjects research. If you obtained IRB approval, you should clearly state this in the paper. - \item We recognize that the procedures for this may vary significantly between institutions and locations, and we expect authors to adhere to the NeurIPS Code of Ethics and the guidelines for their institution. - \item For initial submissions, do not include any information that would break anonymity (if applicable), such as the institution conducting the review. - \end{itemize} - -\item {\bf Declaration of LLM usage} - \item[] Question: Does the paper describe the usage of LLMs if it is an important, original, or non-standard component of the core methods in this research? Note that if the LLM is used only for writing, editing, or formatting purposes and does \emph{not} impact the core methodology, scientific rigor, or originality of the research, declaration is not required. - %this research? - \item[] Answer: \answerYes{} - \item[] Justification: LLMs are the central object of study. The flexible-agent worker of \S\ref{sec:validation:setup} is an LLM-backed scaffold running on an API-hosted backbone; the Research Rubrics evaluator is a GPT-4o-mini grading transform; the SWE-bench reconciliation analyses trajectories produced by third-party LLM agents (SWE-agent, Agentless) on a common API-hosted backbone. All model identities, versions, and prompting interfaces are named in \S\ref{sec:validation:setup} and Appendix~\ref{app:fivescaffolds}. - \item[] Guidelines: - \begin{itemize} - \item The answer \answerNA{} means that the core method development in this research does not involve LLMs as any important, original, or non-standard components. - \item Please refer to our LLM policy in the NeurIPS handbook for what should or should not be described. - \end{itemize} - -\end{enumerate} diff --git a/ergon_paper_overleaf_edit/figures/ergon_dashboard.pdf b/ergon_paper_overleaf_edit/figures/ergon_dashboard.pdf deleted file mode 100644 index 6e81f05e9325b4cf8d92c7a1132d150db9d65703..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 154298 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%0AMF+J>H`1 z##WKvu?C32@2Wp??~qg-GSHDR-vnMgh_)4$bUms~q3oC`bjWw+u`e;NZMQ0o=xq$f z!E&;q{Wi)?0$qkpq^m1ua46=n7)g2D@dNUOKY$1*9Ko%^(!K~gMJ#*i2O`%Jtfs4Y zdl7c`Ru!Z5=3+0*R#c*!Z{m})!OjvID__(=}D9( zue%2ezFFuJ{bgY%Ce6D@@h>`_vLTLwTW>`}D|`$YU(WibBs#M!d{T*nYGM_b+9=M>F|$R; zisX(CX(qKiW#!91ULF**`15rmhts1$2pZ~Uh8$>A4!aVw&aiR!B7$L!>jF}gY zazFXz@k4DLY=W5hQ!JgwZ*PM%v@wcavWDu!O6y}G*&8Ii^MH4T3!4Y_ViA^A&I375 zw{kM;S_YxVS!Z#6`p@lKmxstOf?%U7uuz}AHWL;-tYIEDy?1-dR zLAYE17_+8p@sPz2o)hKwhZ7nmcSi3D1Ci_tkh-p4Q`{ib`w%(6hJscRcL=CctQAHkLye!h!z%kosW8U^60(Bl1Vc;`Jr7QFqE1Mm!^9fpjdBpEEW z)Ptr%1;K!@=Z5SrR*>i|P{M$cmZ8|FLW7IUFY739>G(5dQslN{L;M(AZ25s~T1TqJ zc$Le2*G`lK6rq@VCJEC9UHhd%+01Y7T!tlS0;d)%6Qp_=$aFF+>yLO&n-~$7FofRI z7TIXeeU|%!cm*K#V|mY;fs6okPCJC=UMQ9XpBEs`d6)0vYe+&Hf|N%S()9*y7kbpY=qj>e#qbyW%qb2wfa#>RIk?`as4Sj<-n;*Xx0T z+~4eyQDO2xIrpyUD9Q9WF^nJY)Y${*fR0Q^E#KU(c)SlH2tM|EPa0fWgll5qehdm@ zmj~lr*Yg1nb8&nTJnQ#h}69PVW#Ylqgrd;OC@(H39RqQ5&tN0-_+ zVev?5MDCJ8U?(W6Tsc^h-I&+_Mdcgb+)ir~faV3!zA8`?htP})uZF6JO`!amX*hh^tpBgXOoO!7oDXi$D1J)A*k9WYk4{7WlP zh1d)Yb;SE1%@Gug!f%1C^hTh(5F)5alB-~6Ugtw)zij!wsLoNtFVjSgr~f_u^`(tm{`zWg(!=f8j; zeKE67LokkdBZBU$4L8AiN4Rh@=Xb}E@D_0J==?X4F zE5Eh=?%GSFt47!<>duQ81_D)5_@x`{Af#T!MSAvB8>;#yQ4V)W`3{!ylH7LIYsv@X zlx}bW>3XUEF1Kl)8N@!Mjr3hpF(xV^)y{6{71XoLhid7nti+{>FoXz`DnKksEZ{xx z@Pdn^wut3;YsTW{7)de>6kkANPoka!h7eD#_9GS<5Q@MON?a5ZbSR}8>Nr$pGKf@^ zJ!yORH_FaN4*TToAp!s}7YSZxR+QRF!D^eRL6fO3|$%lzqZtVa&9??L2iS%Sg-)Qo##C3VmYosr&6fb&lk~mAb z5p(|lifK!W2(GkGgA2x0xnl~tg7#?UUxSsdjslq?Iqd?z(h()F5J&*-W)XSg!EPT2 z{$VlbBY~wD1Bz%P7^;rB$~W7s4D|{QBBKZtGq^Cq9@!hKf&SrrNSX23-9hDnJ0L0L zqfIG<42#Fi_n2*?vP41evN0)}-2)`_6gi?(3<56#|%+d{?~0Y>(fS{<#XQqHD~V1S}w8Beiz~ zY|9wjT($=(XJbBDQAMCw(IF$d}Kde=#d{QWDWhOf%Dr?plvKz}-DvUMz79xd&kiiI%eHmNX zvX@fK*s^DDF=Q{Hg#6A+pYQK@p5wUxc#h|f`~K(lPs^BV=DObR_qn`Yuk$=(6c5uA zM`3w@DA1)*s^es`G-J(>4~>$|ZUXmpY)&s;@X+0MabuVc0LwfEl+{GtsiWuIJ~x90 zSJNFAXWcKj74H4<)m^v={kzswEwvjuGh;BU>UBee%(~n#+y)FQsbFl1Ih;w$bxVHN zd6l%Qgkv5^k3gi!CvYE0o;vgTbf(#AN8Va$5)67_R%-g=!q1g-<6*3hvct40A4iw= z+@9)sS{hMMF9@R*$rm8<%)Rvqq8{S?7jZTQ9TdK>UK7MDhaKH`aOB^>-MsQZoE$x~ zbzi>E%!6-u*hct?0%9QSnW(y}r|XWDK$>)hVaP}Dg5CR995OT76~Hy12+mZV%)t0l zN9G>p;UWQ!2scRVIy4pn6k@#V@mK>MPmjE^wg*zjy6 zxEb)l0N$0Am4UmTsqxy$@lXFHbmebD`P2q9CxdA{U;os@E<99r7f#GWm_`i!=GG7+ zjEGQ%W}hb=(5-RD;}I%${wcs>i2X#enn&cnmufxcQvdzF82d3P@W@^8EUI#~Un=rdRi8*dXr@|4S6pn2^B8LfS}FSj(b0W#aKQ)8^Cm zhdPFDd?gJrUw9W!rTrsLx7Wu<@UWBp-JZ3ooW;O7_c<5;rQ-2g0FOdJVmms9HdYHl zg;e`{Pi!xS+!#85Q9Fs6=!&zIl=krUo|+d*ns@1vgb&S^*^Mxee}B?8lU;v13e3nX?ap%b1*+iav4v3RX8T@DETBZ{EBCJ5Vap32{m^ z_vN(2#ObN23jhRTRrvY&A>vPzwFgFpy4>8{jI6B>l5Wq;6gViF+f~#Goj(2fYj+lI zsJr{Qr&9lT%4a~MV`F0_e&LXCEuZV}eI81-K7}(T#Kj%u=3ZJ{3_NGj3XKg=)`SpD z|07_z)L$7!6%{^v<_b}QUUh4cBLIGYY4=eY8you%9MIR-hs6}wR~U3wTH4fceR6Vg zWn~3`@j(MS&ms2WXbpAs9-Q2T3j>{um9tI==fK0aA#mELQVBB>I{s`LN zF)*-3=6xFf8csMe!NkiA5_fb)8-mfzhCY6L9779(DqW#jj)rFwLMXmN3a*D8fn z01f(Z<6^5&4|kWgUt=Ti{%iae+C4xB~9m$Em6GWw%*cqaY8;ll$xXW&J6 zRbl7Vk7B0fHkzKEo_>CQ;4}fh>Id@A)YLSNbdfHfRrWo7;6pR>TCI6QQH z{OHj$NKBiXm4lm*shbc9OG-;S7Q$1xHUHrpo=dB|y80HxEW_@Mj4{x0&BR&RVG-so zAEvy0n*&iL&y|OdzLXXHbL7Z#FvLYgMc=-CgUp`PreML~s~hjzY)~^+-^hU-)? zHa5Q1JP#wkdipXlGJw7QvAkS1+6k7}-NU2OeMa(t;8KL0cxu%Pdwcun=xCVe%zgHH z5Lc;+Iette6|G(DPLqkI`S(2A7eT|8F_)y06V^ICR?A)8E3l|KlPTD3nG^}zj z4G;0<`RMBELX_|Kb5U5~=FC`rdbfk}J{S6^dNg%`zkgC5g3warsrMb)iwKTsL{jeE zyN3)BBpf7PMwvf|iAj8{Dpn7(H0_vCMz>&Wf!O);XwBHYmVlBWU?;fnhK2_GLU)!b z`f&V*uFg&^LJdsO5F$zTDBHckkXM?#d8KoY!$rPES8c9!I^54M6dk zh$^I-uV$sbxDM{VQ}(h)vd$ z^ZfdVdK8m>x*u?q(|F7>gugs=6kx$i1yoNzg2Y^H+yl@Bcq=BU&K8I++cI828p=l- z#;?+EuXo*{qy(RplER6xlRt3zea#38qM@rmT_wmDg6)HzL_bx&&o-5vga3hQ|--4*# zFvU~koT;5%{*zlhB&H>dLy6Bn@jf9H<>lo+cw%IF%J3R^=e^|d$<00ivuE87&HE;f zDS9jQGy#L-*d-XnDEuSGjsbZlF3fpMPEO7*vF_KeTNtxp zu=+W{nUKa38r5!}E(mT+1*}}{N`E%yXj>V+MdDQKL*n6xPjAB7${}S_xOWsQNr)ql zBhG_CF=QSK16QC$1nL|HeNoJ1T&5<$PG5|jzcmoN$aq_u#*$yP&yodMbz^moS0QmU z;X)0Jcr`FbXWWFnvC`efTT$B&x4h zVWDKQh)`&f8WHe&CDv1ip0L}&B@Ehl&N-rO;>w+uC&@Ue{PvF1{VTi;$Yx|{F$`QY-6Z{n^v!?mD3{xHBo36iq^RlPHcL{P_ry3na{_u25E2~w^ zUQSNV1j+F3w!?=H8`h-uzq$|4hQilGC@n1|g}|I}N^$=|2Z9==B<}HJWR@f4CJOdb zn$|t>fN<2svLhxXW3mCiEBZ?)xQiEGP-v49jdgXq!IB(hVnUTBq{hn|u}n=)<|li= z=6KTX=H|vUY@*q0r(${DzG8CHnxF3nUtL+@`uaLi|ECF8X_Z9VD^qE9bh_{ozsLtW ziO=_3=q1l(7ok(xz;DzDCY2!W$F=6jPW^GU41qw1uW7sP%Zz#zXDWN3&u%Qc0?UTs zjIWt{Y(X@|oS|o6SbZ)cDxmW#$;r5+ptBb*8fBgw>Vyo# z*sr*VV5ff}N1d~Dz6S3@u2o_hfJE1XAk4~|`0ACs{g3wct9Y$L*h4jCY?__6l5+x= zp1uWIuF8k~V0~GfR@4>PE(#Q*KY$!(=UfwxJEXt5a2a)r#aS6zb#v3xd8F3@I^BynC*`*DlzE!zr6q1KhyVPeQ#Gc%LGC?5)MjUUhroC`bfBnJ@JQd z!Y|9%I>yG#GwbNtPJtUtn*P+=tAPEqevM0#ooX`qs7;1lu-8sXRkfw3!+FMX{Mhiu z;B0Z;sx-3WU5AE{?2D$F1DFUl4vu;t${-$Sqxx}YJ$rVR?yNVQk=U3jl&Gi+2=|rXUbr47q0n$~gvy;w~i5SM`&xoq#WyFapFAPDK3pLBEGSO;>s`t-N(cO>$=K zDW}h#)e*Zjbg1D@UiL;y47U+-f3q}x|89PiC~(!o0J|X67bCYEnC+=DSCAZ(ZN6i7 zF__9Ywy`o(4#^6TJ9i+pAlxa!RjR+f50S6K`NM{3&Abo{jKgRLloZru){G)J0G5-y z2Hng~`eyHaH2f+-@0uWRU+QbtK&G#)t$q6RsSG76yY;at7&4gvNRx#w_5jEM>>kY7 z9Ne@2pdAeSi;L4&Q+rV50E9r4lZE9XGY1WX>U*JQtsk5A$c1ye7)#4w=i4N#K=Qyn0-8<#~Cw*gTDSkc9xKhl=iKZ;#K7V|kQi0f7VHLq)5a_1Cjz z08R%=GZzW1)-)F>g+qXzwb8Tw$bFQkw`L74jr;buCg`??3f~0sh??{KiS`j#uUAyX zztM16Z2DKo3V}teLeb*3c`=tz_-;Y9*Sb17Crw&mjY@@~DSaz6nKWQtT^KRyDS(w# zNID+_?UYnCl#51T9yoLziIhnln8u`OJe~hGVCs_4VE)|lEodhz(c|OehV~JU9txn* zD!ZAx3kF-13h0MPIyC~LzXK*CxPs+lYvTd#VjS|+mjCdfoaD;*Q7B*=jR zk6jntV;`$3nvo|#(CUNddZ35*2cipYUr#Y z&>9)OuXv283SmJPFNONsOAAmQGIc@~@ytA56$NxscnwaMFn{F9$^- zMR48zra;FE&6t0{mlO0j#(N}Hj^u!6)8q;N2==K41be=F*rMJ6bebTSBRZwo{qoKo zNi6DjLWN$E2NkBSnyxF^X-06)<8Hugy_q$snQ7$(qtvU|C0 zwo^ zM85j>Cuo1jzf}jwzwPAzzSL(6(Dok?0~x&e^7ZQ_=h13G%lw|&;44U?`(FN^9U~+! zi!vGf_5BMo>E8eA|Nmb2(19H%_xB&~gZ{qdzvrb7JH8-)4;cOD0UO@PJ=4%QoRHnQ z>pnV=Shzx<0wkeH$(m=+ z-b2m}zyQ3o`t#@DoVBg3s9E(lfR&Syl1`lh-NV5dKyez^z!MW_5wa9;rQffSe8hRF zZb@$Eo4isvd2%O8?Aan$t=)Vxs;r$Kifj`bRhg&~oR+^XIx#G~||+mbSJ+l9#fDg@vbQMY9$d z`~=_G>gr43Wq|wJ+S=xc9L?(HiyZ;H49xJJJ$u@U^v%qK>ICecOuFiIj5m(H^vT|b zdhQ5#SzcZq331lIW$FobJ-vJfC_^I1e9Z;xq;9+p0XHqbbxh?E=EqeP6{#(9PBAT1?~_So7Z~A zj@p5z`i;YszkdB139j41)C^G!AZaWe18!js)cHL8-QXW&J-@zhZEXeRpwa~x1zCzf z)Q!=PL5gZH_deqdY-w~OVapeCV?1}!nlJEDWqU;Hzui-Q2n(O^j|LYy1y^AgTNJRd z@B&|)ZDJVX2mA?s9Wq%gR?Uvhn>a90QUOqpg;zhejJ@YR5c&uRaN$F4&YLhsMvhB5 z(dOQ_fZNq`0*_5(o^+k$ht$Oly1!|7fQ2rAMFO0_KtlM;nJdmzl^14TXJ?0@;GPdIt!u!1iwW>DItt5 zh{{$$eW30O+lv$d!OR`FiMA`K+k~P{2)`7S$xnbEsuSoRg4r?GuYUwcdF$o&))K_X zk4`!Ph=Op$utR2e!Bf@)PkvrZ4B}Jo9~~EI_wKce9l8AHJ7Z1idE00_p6-RUJ~$@A zWC7{cFCjWIrRXGi0_;1#G>kUjPhn`%cEj z$Hzm@{!>y?!w!xJ1l4VK8$uSyjwQ9Vwdo`@25Z#H0`Sp+ZG|WO1rKonUTBzk)&`u?|ovh@pRX-TCta^b;5sJCoj)7F7by>A=91C^lK( zt$@P~Tgy*If-pEC$ZiD6fF0s-U5f z2;mBWKjei4VEYJB%Ca7d#qbu8_%2;C7F26QHxVurr;n_^d^cMu2by_2=IW{uftHXE6u%h_C_)_D6Vb*P!_v7#D-eOVZMF`x=VS zS4~Vz5=wb687N<{mr95z*M8f%ZsCH7g-zZ&xZdo@b1`VOOlE_Va8bBLT}#UXkV~*s z_+ZGqU=X1*WO;u8a{xMI384SJGy}*8c%|P1cF>TLD>m%}K%l!B0La9I)#KRI4r#&T z3>Cw0=?&P^8ovY-rXVK<{-_J+KNb36oNR_r$L#y&N2iofHsesIf|xU!^!sObTGr@9 zqI#E?my;p1y?x%K2jU)b1c@zsW^4LphY+i}pTW8mkjQ*k6+UMY-;qyKI7$ieggqP9B&nfN%7dl7(MlgM++1c6YdQf*wp)LTBA>NEwlmocn zXZX;b7y&*$oyw131_%`p_4+`y!?nP5s=l@?!Z}89$=25YCI&|mb2}nTO!q4(B&F!4&h_ z2Qm~|TH2PkZ>fMtxbIeM!y4{q)%S-n5uV>>{ z+H*gNM52v}B;=+2_kiLQ`0+KJ_+uU8kWT{0yaKE>5ba}NFbxd)Kt)<a^k&sPb zs`LhUPRt;nSqlJbx=-2NLbU3_aNsz)J32bbHm*CpzwaV^{zG{-H6BO*0X7sbFK?ld zBv}2VuDG6wiBh>IF2_Re{1VrX?#uVio z$~O5@@ktO|y|B*`BqK)sh%St77WFHBx#QwQkwX}kefYx%yFC@S=pa9s$%8o!%&B4k z8Qd2-iXD0}lXMArQ&g&nNcU7iU+(!nWmQ$v)x+^L#-FdkGb~Q7|4MM9Fl2OL?8Vst zvKI*(faU1X7HETsSQUV=ZEoKFH~O&JDeXvS0bhu;Q-&h4gY@)}V!nq`5CEYnnJB+1N0|Evq4zKZA=ZxI z^>r)1Kru~RzBZGXJrQYi2q5FVS73%i?%$vF;=1*5m&vcvJ6jswxv=w@s@2ceR#!vv z!b7?OHfR$P=;NSL<8U`L9Dvhs^ypE@FNJ$4pqsJNKTpwOJGh_Bfc~D)s$*dX1>Q}; zwBR>40QiK|_94VL*}><|oddZFq!)ree5v24ld=Ewk;#~yBjD<`6HN!%roa}E zeyTIbHLy{HyyyoH9iO*{M`5tq}cz}+9A&$chD9hy@M~&mOb2R(fxB_+YB{#MKDKSP%vas!glg9i@iu;xMXPPjz8*lXeL zz6_t8eR5+I_Q>*DJp`-}X}jB14)=Ho7T$GQcqtWkvv%JyLN2?GSLDCH&4&`zJfRszO%Cv+_grMy(azVn|C%bN7ef(ALJrK?9~x-~JMHaVN*2(P{h7 zCxVb_?&HVz;7P@%cW-{piaE?JXZD>1I`MY{w-5*<9}}8#<@K944fXYZLxB8%zj+U- z2#zLm5pw#l)RozOjlW+^Bl5pU40mqxzs?!|_a)>B{3nC{|N1lz={h*ff-TEN!UpVkX|%k@ovw}#|Y1S^k~k< zxw*NerE6}~`g$42)UX$yNO=hWe@qWCF4(3*{S8ErLX*$Gv*dOUP+HJtx)(GBPj7vu z!}J~8ujJ{u0x{_ss0V?xS&W+yACK~ytO8IDEKU_>cK-&1}LV`(p8?D`zd5scpIBp2EbV1sHPE#Ro)9YCyb7c z4w%yZgsX>bA;CfjkCPn^2~VygNHxexK|*;S{x;5n;!3-GClJChs9($%Ipw=;7`mee$l5pzPyiThZ3^8h5GR*NSz*xVuC5a_;4sS6kfmV zNkIE=K&ZIAwEahW_bK0x$m}Obl0XYUO8&U8fKYSGX|>B792|(bYi}~0sbuE9js-=j zh`2b^r{#cXy9!qsxa9}#ntui0y4Sn~Ji0?jM8_E)iVTy}(vkyzJb42`G<~CL#P-7) zLqSB2(Z2ors{mC^m2IMULctjVMnK_L;w8>wAXOScs>^<_%=8pa%EZQv0vjtGo2!*t zYum*Y?!OZQq;qA^o`RKR%ZF`2H(epF(fsb6z>jAUIgWpT9E7|haHU%ngboRkT>fyP zSw$_7c@qW6C$)u5aK7DRU{ZT}d;M2uu}KU0%RH~>4?iOTTKL|89u^uYZWlJmvIvqyQ=+_r;1lwD(Cbln z`P1u3*@mEk$Z-UVHPF?itw9@FSXd~dlk%E{{inGHff)hkfhb*&B{DVx*Zpx`Vr?kBhNE zDC0Z|)FjYZH*Va3FcH;OGnMREfrW~`=N!YjJw?z@Iez?s49GX?06QWw0Ek54NbA0S z7AhI-V6Ovw3ce7;w4Bhe3t(}g=rxg6fUqtCz}BBXUqVtU z6k`e4!g4m?Xzs%yp`i)B&)f;Eju)W}aiL_*bD4rPZ6|P2v?wf)e69mRbVgWMvy)cV zJHfbneB9!&f;Z9yqvs5G$q6{;AkBUf8+%GL6EAOiX6O&dxX#q^(v~a*)pJ@i^mw|9 zsiov)XCLH}`LL`D857thLu8$Ue&5?ki}Mi)pnrIx^X!Wc*#BSwP%{DrFSBqAh|hF( zGdm?%8!<%~@3%4Jr1v8Hzz})tpHLOaE~q?y^L4<+adqCsA*PU}AZn4yW5p1DLbRec zU@3r)js@O$ab|{}3JHH-pFy}a*xYkqF2jpZq}T@FVu0z^FiB@*$r;%qeAH$%~$PZv+k67f2kDdu@h0 zZ-YHhP*8y3cm$!u;)KDc+Er%?g<76$0t_T1s5Xi=iI>T0_6LZi6QVLfHQF{AW8Yr@ zc{~S!2*@MmAdn=+JA){hP(=*938S=SWMohpadyJM1`q4#kt0&JfO3joizUX#DQ7l8 zB`JU!%6ji87w=_zmW46p&1gVf$QB?;_R}MPj0y1P)XWfO%c#gp#|d$)nJ9DZNQjL=N;H8%~Ho|6u3 zfoV(w$V4sLNbib?T>`r8#OaPgWGQJ@_w4|_I+rmz&{ez&jFINt(cFEKe)Aboj|wId zvQ;fJ#J+Hlb%CXX!jcf+_bYU#?AMr%9))~L!-#l5T~FlBojd$(3&3GiP?_{iZ0vDH zmz`|noU}oBbJ@D}AAU7w)Ci*V1tLM?@;5||!pHaMnZAZkMQ~5;cZ}FEZf?<2qma6k zV71iLK0uXgvjLNv31>R$24QD{=J2=21Rn?fbsCI}}wnOg~EJEad}Q!%78pIL(+V zz3KWRK12XxVPUcS=xrpc#_&Zv<(LWDE%~Ndv_YC$()0(xas_Mc$-BhoCO91K`t@`? z=fVkatAAh>}oZK-j^wj3jy!t|DbTgd|^~*#h<89WE}evcep0KHn4}ZgIOu*}?9E zy}kO zttuaxxL1Q|w>j8Y)RILkEblq ziK~_2C}DeZuGJ!~MFEc~QhSmez+GvAj*e~8cQsrLJL#Q*^cS+rZs!@lt3Vv8WG%Er z*vYcw0~B_$#y%z7<@0T~u2{x^oGsw9e0aRj29b;lzp*0uNdTJpvS1!kp6F^ek z6%3G$hZ@r6uM+3Fvs#eFfZjqH5%x*7WVeQ8iXZ~Qrf!1+GB7Ffz8Vq|@(_lamPLa} zaK*7oNpfJ0QeQ#9S46o{sfo#+94L#+6i@DZ&i1=!`MKe9!?ztGbwITS%GvoBK0TXY zzt@T7N^x7dwU>M0+uGkaUz0WtqW;{hZH@BCiyr;!IB#ErsW`(lMgJqkzw1E|m$=w$ z!qqm!hqsMakv2^=NdEfO4jXv;FtYWknRaA3>0D(`GPRTHOfl)1v}$EC5)%Dl+GP|5 zVPHY+ZX}#Jf8GP22#_=l-n*!Q&{n(zMc{dW@+xsMOrW@02A^=qO%sjYlLLNRSUK7YL$s z+Zxo%ho6gK2Q5n0Q5cf+CZZ9UbCI)XfyI4KXb^qn$C`1c)+ZDFQA!W(&?qadx=a z4t2$%!M-)KlykbNvIRv|S*wQn9q;ER&GHL7bXdl9Dd?D z5vy*CaW8;VXNSkoA|${rn;b{=y*cQ66b=?r`MM@V;iFPgQtD^dq3jXxF%3%@;b05~ z6C3(le`(?+!mI%q+`Aj)D@ATtSq#9jmOz0wD+`Ny2hr z(h!u?VzAU2B(@jhw)8?8-K>85_TER&+XgBbAeyb@IH+$calI(_o=qYe#6HBjyr2Do zrAM;=K#Q?o6Wf0VmocltfXb1|r&H>4-5(OCQ_!-hm1g-~-tmKP-7{{gCu_`!-kNMZ z;;SJ1qChjV!1mEFOVNH4;kfxKd`Zzl%@YF($6g2C1ni37^h@|zOZ%JdF?ut~{Yu}o zlGuf0xvoPhi)Yc8V!v?`2{AC_qJc{wh_wTVS4kNKeYKAJx%AmQ`tEz$$P=KAxp-BhM#>OkC=aPG0 z*^!~@`Mkcwc;L0yn7UzniUONzk0z#X(j^2y$)W!Kev-uG@vN*YD&J(ywD^%x-n9~rd~K5^%9avUju9F?D?0}Zy7m^hCK>3TZm|75x}}^Oc)e&K_Rpl?E+GS zp5qQVfH14!`*~0zgBOIE>-j8yFy%OIU)Lx61-*nnOqFcBA>@6P4K1 zFMPGieQJPP(w6i^T_*OH)$iq$_Z6Ai0sVCRE68VVf)qTz^JzR6?2dc}V&DzRrz;b6 zAm#{>n%zwLL{fEUBfD5jQ&;nQnORv|nw!-Wd721RnlwbSSi6^&*7U7PGSmbBCHd;r ztDZW67{W32r)8aX;*_sUge|cIW}sIE%_JB-D4`yfWEWH|?CHq)F9v=0g^{ z4QUJ83o^3(KxzrC^LqrG*CKvqd-AA_IF#P>jUi30uYs=vgZ2xu7cA@Pq@2lk_g|1+ zk_HTJ>%p;AvWN#zPF9t)GrF^xus554ZGqvb{~E#vf8j)t$_qB65>nKsoq8M^FWUbxnaKY?Ny?76Bwfqh zb&W8x`Q+F>npey!8`PYw(+Hq3dvb?#@6%nBJLR5|#8Ci_G1D5wiOs!coq=%B`EfgV zt&VEVMhJheZ4o@hAi=9wdd}6XemkTOzaXEwQ(X}xMR_yW_xE~zd#;y5$KiJtJAb^n$?_a+hUFPHJ zeDRk(zw*K#3E5ANcGIMHYDgbF(qLP4Z1|9`R$weY{C2w5>5~W^!-==Go&LnmGWwY7?$uY!_r;m;^IFDirlz^71=@zLHRiefvRh|H zfb^i+h@HLSl^7Kj6&$RjGp9S=G~!RUe-?K2=l+M_Dbt#T< z?{?A4Yd)GVBpmMXL69OZ?wxCt&@A65jEjrwwgY7+6xQrNzc0P^&!!_aZ7VG2eU4QM z?NV|sjX`2@l5()ShWG)vXTg1c-+6qwsW3HZx2xOA&)8$Ye*Z=NPW|^+RKeZA&wo$# z*8hGM@&wX%{qHI({@n`yvrprZApc1rA-#Pnq-x_6QIzY5;AE9h998yQ*V~)YZ6Vo5 zj+DrH{^2^GSTb35-$#0si*tlSYv>@9vrL7~#P%(@mAm9FL3=m@pRN z{&;ERL!Fvo{%mb6*FWkad^0B}g1|x^itD~D8Gr3MdMu}*kB=7HIYCusWUr0?y6@?M6ATO<7+RnoJ*S3DJw(4>ZGTI7?A(fmU;9X} zmmWbK^l>xiM3;-~54n;>X>VBJ4uoIs^K!q7O2oX=xOKzC_;KFxMNS~6le=#xc^0Hy zEjXxTk-BvY#MtqwfdOrz-)q+e>*4(-*}?FAsgK%FHO95#5fN(0$5O1suL+scFE?f0 zEffQG;_7~nEBUi&Qmp?>F`q31$0M&YX^9a@a!6^?X>HAf3ot*5MAumBRSFFn8#g2W z1Nv#BnoUI`ud8G92coVdq#znJopUwTq=mazA|vNU6X5a5o9mAxUcKTW56}Myg9P$(+VT_|tM<{zZy~=m zQYe+xnvQ&yZ>8tDwYa;Ywt4F5<>oTvC|u^a}X5neNB#zG_V)ySE|x+oIQ ztd4xE1S8yh!f{8zlSTSUpZ7?-8Xw2(RxAvys=A+Kc3bQ6WC;88EaPG2nR>>C(9 z8*t%_w_XS7-uh}a6YbO3J#s#mY_&Nv4sR3d6U6c94Y-?{I7>Ljr>15p3mHl*%s&hbtsxPIhNX;G z-0IJIv>I5SI8Z>4DbJ7YLg8$99J6#Rlp_IM@-V!4unodxqpNFS&Lvn~@A+*Whj9|r!W6E!e|WqRgIh~Fulg=`AnseyN2Zl0ar4DbsB0=J z)B8g3SHhW2tH5)(@x=x-Z8GoUw@uAJ-7iuA=UXdTt<_xz@4TAuh-qaWkPlb4;_k@TY3 zz5i?B{);YbH%+EnuKj6kyPO#1wfLb*yQ_rAEYjN4lyk4r+dJCbbfn;M;PU1L+Z$JG z40*I;3I?N$@SLFy4OS+MFAB{(jmjz|=3a0}vY_~GopfZ9UJLUCz~(L`D*JMB8{{$I&qxJnL6&pR=}9;wl!FsisgrB$ZyJ8AX> zv$K~s&B8k3`#g(dy*Odm!fwT}uJUORZp`?&JQ354EEB-AZ7YKsn|0z(z4MofSxlDB}-GxI|LQBfU zCy8-`ie2hhDz5)_J^mnu^m57CDJeLa;{M}t{=TcWh_tECZSPKU>+m?6q4M&XJeg3Z zw;tt5O0gbwr`-DTmR<%1GH3M_+)}R>kSd)puJo6xvKzPln%+p%s1WL)bhmzGyu4)y z(hKpxtsLHp(EUo!YfMdjH;mp1`Pdo_NnN^hjgK$kYMV-O!#$<*(`B8riphIv?L}LP zYVHKA3_bi|L~Opk?fXTtM%H_?dBuObfTYG<>D5b2#YufA^q>B2bzei^OxJOC*6rTX zJDWec{o5rt1r8q&Jgg}iXss1lQo&kMDB^cfz!@tUyf9CZwfM2o*_ZnKo==p4tE(%& zP&?u{XlBpu-ifv(u4=O$=8_`DrObW(cyirP&wX|+)nEHw#JoxR~X;P)#fO3_$Hv{_4QI#vHqvfKfMMX=M>RfGYd9hwo$H`JNv6BUCy}RyL z*m?h4v1cA6x3~MPQGMtcBCTDvx28OBd)Uy`f+Z)#E4zD}isqjbkNj+Yig{L9dB~aL zL#dAsZI}n=>uA-~s~C+$#h)UamX=>Gx{Od4`qeBXJUI1f%nS^i%jd{uTFzqVX`6hO#pI;pNuwc`dDIqeJW`RU6zpa`A;1 zWZbtsp{9Ys%gd|m$wjxpv^~$=Y?m46Cm;BE&Ut!^X(YrZ<#@Hm?KybxTb!c7O2K5G zIKSuI==kF1;c|CNm(hBqDjtcApEHY}K9vY3HeZzGIWJv@!f9w~PBc0`HFTb+sw|A; z_b%2eq%iP~+?<^0&T>@6SYy$~TuYy_T4J~&)zvXSzB~6x72)eH z4AAAZvVNL#Lb{4!X{aGiyi&kwWk)CN#julC2C*3ts_VDLZJ8u|eSNh!q(6g|es8qB zITqHDvR~A={&U{7?fANh+rPC^1~T++tw;StsUAFJ|L0BO5@qbydCahJB+vcBkFKA2 z%ke@jeRY;0<`Da+cEZY5t?PmBeGD#CYHDN$*PXAObEPM&0&kJIl^Iw|Z%uEBuEd^i ziRQ>8sa?R@g*Gvg_hEZ!CbnBmu}CKr^n-82P4D4;=}bGpY!O9 z=x0YCEkF7nEFk;YtwkTdy?h7X-fu|@+>&oTvQRSLzTxsRKgZ{3;QE)URR7gvy@939 zU!Py%L@d?~w^jcq~Mp*TG~UG2K-F%GyZdhQXV zOswNYjCcP9nVVmToP_tcSI0_m^2X#F%+i%Eao2Pn#l|uRh~=&fKY23BD7U4hLA$ke z+uh>_i>PT9Np0dNopR-9iAnFrT+OQmF{otX7ONo7Y;!@W&89<*@|;e->f_5@$v)rS zpX0yU;Jo?jPeVoM)Gnha>e3C&_?Z^LPnLdHa+NRfgfYg~&0Tc5%cI`O-UmvC5p&9~!Mp;Q$^jMRzpfN8n5cL==qe~oTs z;fL#wIOdxCoxAtrWZZ=)g|W5?RqwOi(g%DN!l%bS)CP2sA6L)Mb3l>$5A~E~{pW;& z`SCl`=o<4EZX+JszkO!Z30Zbq3}{Q=g|{t09di3EU&C!0GNavE=P$Dg8&@dO`;1Gi zu9@cMJ4m{Tt!*TE2qhXvT_P=|O~$fB3eG;yvAxW624jf|g}~=Kk)0RwX=3TlxjWtN z_We0+0}t6{+n04?E_+UY&{aa0*8Ys0?&$J2U^pQ7@Q%9omtnfBG{1Q+y8D%N%Y>*T zadSPXaDTzW2OL9WVh6&WxjlL*7Sk^2GftQ*bU$1L_2-VA{x9*g~n=k?W z;L+|vRn}smQWxDDv7}ns#Xl{Y`lT3*W=i0azI)y6)%$nkykiO@f4uoQmV5O%{#^*a zk9vkrjELyvjR*Z!f)k`Yzt?Wf_2=cBO1rh#!CAM4ac=NxNo#DV#XhsbolH(h{qdt{ zeRk7c8f`$#`$he^@1l0yz}q3oQ|C;iBTG`xE{{t)6YlNpe{nSh<@;kOs{0NC;^p`L z71j3DN;{{LwBO{$Sef|B*822tjn~^h+Z&>%rmh8{q(_Cm_puvikIfYr@6&plHb8$- zs|Hs!1@)5lpTCl5LlTqA%i|`KM|>9I_Roy<9y1vlk~BQqoR1wqFG%|K7(0Tm9PN?4|zwnq6jZNj3NtvZivzpoQpz{oae0$Wt-&qPADEFUk+Vc0Z<6d0o zZ|x-OVq=4YwM!^ZGM;f-0xvwWI=S+`22ka@j74btHr5FNnStSbiHkUmsk>a7b2VbSZY1C;?On-jq>dUpKVo*om7o1x zB6OP-uV14DsV$^ovi@_Xe<;%5zlSaU>Y}}-t`5P(ne|vK1WLZEp)E2UM@1+bTH`?o zJs@ws!|BlUMO?ONEVr3q)I098#gkQ1V&2nPuw3h_rp&rvyVYv$%$6Fc0mr z2Sm@ZCdcVxem{p*&C1S>j)@AiU|Gg!6NRo7 zQjN==erY+#c$nVR-P><7X|T=j!778b?KEmAjjJ=EDNc1WnH#@_P)3lfJd*g^Ge-peNkamO+krASdLk5dv8Ej$9++D{Oa@D9J% z{TxFdyotrdlqiMqV3}v$B-Pf{;JgxrE;o_nig~G=FRrdTk}37|o1%?dzL3L0cZTK5 zG4uELTIFzCI(zGX>&vd7U4MF-e=@Ky|{%-&DCTpjLf19$+P@i@jCUz z*x6h1E_kl$vi2}>hsg})p7Uj$69(j&j4opn(!Ju44uD6fZ~cj~s%YKbx~~me;%nS**YH>#Lj7ji8sM77AU{>fDA@Q!IxRQvqk$u?qAbmM%SuLV-xG3J5ZvC2#hq zx2%2Q9J!p)-peBQeSVIX5hfSAnJV1K@QqLoq|4lo;>|;$(a?>LL^Ai12w%3*z|b+e zbYM;P(MK}ySe@q4=GIn|@H)N3G?3$e%Ln_S;qu0t&=eM&NKL@}!uE0BxZOd&(ZXm6 z@C7HUpNHjz?RHR5qB{_$FLrICtH|6x##H2Hcl-9pwycmz@@~K%6O##>>%7}smwpMf z!Q^mW|2MVFdUl-Sjd(S|fb~w!l(;yi-z)5&XNJ`J^P|`e@wN%yKQ*S*sc!FjW3ceZ zYNF98_*kv?(w8Ofe21n2{qC^CiwTO;BC>meI}gcJ`+iAoZ};@~54*wh4oagvJZx_N zco{x8`aW;s`aT8~mC&b+9!9>7rOB>?Aq6pof=!FX8iOJ!{GnQ7VT|n3<{7gq&aRDe zX!7W$N*kK|%b!}iEM<;W@=ZOaF*lag_V8(7;_D~Akzt2td3;AQa|#WZG4#e}W_Gqd z7GWxqdchgrEYDcQb+9l~HG1G=UHLH7+H193X?5rKYFGAT-?hMNu5A?<%BV}(lb~nW zLoo2@UDW1Ui%F@>Hq!S;`{hlyDKnT?_;?3`&+K?`PC!jbitS&AtD|?D*o;o>IgYy(Ax?LDi^Mm;Cgrst3 zn=ixm0C8n@+~k_6LLntZmtYmnbtk3#nPZ+e80+pd-8JvGq(e(De-I2c$0Y`@{vVpo zIv|So3-_R3L_tCnkX8W^knS#}yBn3RrI!Yk5-DkEkZy^kLqKxrW*1m`$z8e@*gO92 zz5EBe!_2%B?|IL8&gXevDxoQRM10WA{DLW5hA08?5NMkUi-=Ij5L~s^{}vM?=5weC z2;k-Ai`udP@F1XO{s4W$BP0YEICP|o`%eD(a!Wo`8-xfC#vbSFw|U%d1sk8AWO%nv zGzK!@;WRCtgAkB_uC+avtl2^X61?fJ51x=_3nyQ$=vY`;ZKar-8|s>p+><2{LXs@E z=%htF@Y}UOu;WPg)Y+|~iDP5s!eeSo-{7vM>!}oy`tBbFk^ndjM4e;#TYE&FV+?*M zT(DY2Rb0ofc>@fgBt^lR-;1eUWqR&k?&)%T6QQbIyA#=$ALLw|;m}Vk>XXkoP1STJ)+Tnr#cL%qV#TLLK;d_TIpenfRp21e zoW}2=nHI1^r!@woM53y*H+ODC)X4mcXvWpkBIC!71(TZtxASYvmKmKl7^`ISn175 zvsOtF5kQPTgraD0-`oXgnu9yLXqTZmfSmG*F%B^PuFxyzZjZHEa)yUwR2m(YMrTIU^bEAMW&d`y2VN6#qC?_->P#FJTZT|u?DuyhpUz8Rfp9pN zGr1??TyGhI4gX&qf$~b4JGB@iu$>fSm;PPCUy2nrIPFTX|O82 znESUDJ6lV|jB{s`_VcxMbQ|`)jm<2AeQpq&iEcX|R&i-FxIRn9sz~`DH!tok&bs9K znJU;k>f$81mj(yjZu^{z$C&gPeG>pPf;J$9%jyvoLo0ch=kMa>>B(Q6WYc{3{5Nc% zhU`-5M+HYbxBumjJMU#dy;esT7i=|>-uG?>Yh#O4oaW#$0h!+c=@4a?q5jqh4k7c4 z&M}*T*^T%I)T0$rtFbC4b^R7AE5cLtxf#J3I{mqIt&Gg68MQveo-J zzHL^obpwAd5Ey$6{E>7|2sqx1dlX4FUN9K#@bH?*c!Qo@ z9goXhh-6^SC@3g6jgGx{;uV3O<;xiZM3qIS{oikS1Qgo5IzcxWh04Vi`@KiEeOG>$ z@;M?r-Up_7NC85-*HYT^es&o^?BIA~yn4Np z;*Xdc!up;yNJ@+msn%e}OZ8vMjH)wRp8f4Bd~4RMLm(p~nIGBh^Qr(QGH3B1b^uCvtGD4pL3{mUQHwY|4KQKXj`Y3a8B%f{e*>fpDMa=Yv1&{)Te+&^d4&s{z=$XyuW{p=XJD1b32k8iI``mm>uUeBw4;9p}Q_Eb5 zy9<(rB{08;75cK26rn?-XGg~miI39)yrf9t5~{y~mfSppLw8zpDoq5arY{yF-Bu1u zIpDo5@{n5e~wjcfEM`1DeR?c9sdFx?OpkHW2*ndV6JLgOB{uc_YQAlb?JzVLY-_}zwyptgIc0jfpcsva^ZnUp1vtDNCbb{C3E#u!U zRQP_|bbmxFb%`#={SpJjR4r%Q<=y~kdoS))99;)C&o-UEa+MV!8E^sl`~W0Aly%7K7OnWaBlm}jV%>Z5uFS??D@jG zH$Xn|Jq+>eXJ;e*WQ%|6D04m?r^O+p;kUKn+_~9U%u;$*T8k~FcH7e5-GkR86bwRm zeQ>R7um*`Rvw{$#M3C_nu-Z^y9L;d9 zQHu!r=EO*7=;ala zpn8g2+pidjFkptoCN~ew{$n0*K%WBH02WJimg0i&F#bkrYTY!x0_Euc94mzPI5z{+Y4$f*gA6!i8G4$;Qz*h89*x*_+$kQZ?kvZ5p^d}!i)TnAz=U3Ex)6!K-B~}m04hn={O!RAVuyIa?OqTW(wTR?kX19hn zr6`}JcLectATDvLJvznCgthFi0+~lgmO`C7^G);clJlmX- z9>fN$*TneB-RfS?{A4k#BIBE!++=wnT=JFk4$!~auC0Y+2$cGe`Zo*4icziaqP7n9 zbh<{Tb=hYgTEL+U-`>4@R}wxR;<}#luyXuYX^yq%8_@=58($$#$@gC+SWma9U&k4@ z9_2O!^rXl|M(&U-Ik#wwkf#Q~?=u)l^mZ4*vBnLzJ&h=LH`eACmq(3yVPQp`T70$c zNY42R4$~Hg3k#@8=iQ=O73XX)++{iFa;|v~Qv2U`vf{wYVDB=R8`?7G^=n&0jvLF> zB5X&HzYqHq!ao6Sf3Si^+EbC<9b=3u)#%$Wi|^s6K&|RqjL;)GNUNhD?d45tAsK_D zAL5PwzrZx4+OP0nJ1jFrPZygMiRV2Lv=n>q2C2 z)`d6)8O(SlRPy$L$%qOIgEOs!yK7fY=iSXzDv8wjCxLE`<|zN+{|I7!>I&^?6o-}Q*?@daoC3a#T)eM%uvV=L!@^sG4sr;pgG;#!HE^1Ri`Pk zN0|>E=M)*F(4zXsaz+(ZCQ&GOjcnN&Ew|foC6{n(CZT%NcY?c{D+G*XXtSyN-ztvS#qR_)kf=?8KWmXje3pjvt~D z2D84iUAZ{GuYE5h&a5+HA-wqse}B~3v^hXDv)d8ltU`-N-@yP;+PPmlaowc@$>E$A z%3l}k!zcvzEEVxQJ>|XM-7c1Ldoy#g+!0f}bmbRO)l&~r&8A0XI~$jZ{!zK3IA`;B zoq(Z;3%z&QPJ9cU56XCXWZm4FBUAs2@N;@y@hWGC8H@y1sMa zmD}@E4BjPS-#!?`GJ)RKxs8JUYTlK-;s{E_nuyl3uNz$9P03hKlxw76{atA2jr~|Q z70Lmg-+YxjOPv#2o8GrxdDTQUwy9vFtE~s6+j2j?o6IOHsz9au)ph{-6Pb})xkJIy zO+fqE4mp&mShI9Rvmr-A)Q~b$l>7wM%8=3w(4$UQBhUr{FJJt@hLGG@HoucB7IRzM zUO0UPx%1Rrf)?cUDGfk~?Oy8bSy~filVrY=>g;I6%meVcly8-Fb2rOnO7l8X*$F1r z%6F5%vboMa1Yc00k1n-)DCQ zW=jQpIdB$ty5|a-mA|UYrK_lnscenH*q%tvrMO7dTlTLWw%OW{T6_9``Woj9ubA z5IBxrk|e8n1;2IJCd$Xnzp>{J4MEh-aec zBNLNThVVg)`ciAz(BSA$`UMm{p%91MObN*1$#on5QyvaJ2wcWh{@HkW;p^XUkQ6;W zF<6Qi|H7)oiUIGpVAJ|(OiX=W_vILgdk0O&D;{u5Q2Q=l|1ta072{Z%yjuvpAzxBi zIZe~8ag3&gy*GJIlX`qfh;Z0h`7G&N zO*Ln-N7!W5TU8x&**aXD-TEOhg*d$7@##h7=rAXarV7)g{qif_AZq*!Nsxz^*A!|f zuC6YsOaMT-WQlrr?sc$ioQxzxvD!en&|kT#JYD~%?AeY~0pQnwJon>mXeg9>;=d`_ zrWP@~T4Zth1plH826$!10n5`3nA=5F;Ip!?)!tn@gvxK^q z)0w5JEbgLGh($%46=BF7y(~}>eRfBlv)@3Rh4f(YK`otg?d!+d}2h<JtoeNH+(IkPmC*_x^Hu$DIg zNarkfdKB{ZelP9}!gl@T)6(~PYi(UHAa{K7)?fX3C0ntrRTZMCaxEuwQQQl+j{q!} zW@7TGV3JFDB;k35?uHG&hC>Mle>(f~Q3Q`%n%L_I9XDHrM=l#*CRi-%YTZYjhiNqj zGS~4_J}%5|oDcY1#IK^x=YcUq{}j<>zknoW zCH1E?mm7Px6BdQo#xZxE*6oRXSZ=@P`-N>jancrjg)u5941oz2$_x)$yD?iFo#YhW zcolIV?Y%hX%Qo8sj-^kQx6&+Xd8yx>Yo2#DAh=4HSXhdG{78YtW%%-AUaNhDw02SC zWX%W<{+5%Iqxapw9VGtvw~OQd=K?4>O)JXON2}KR^R2M}Eb~&$@AB|TGbGdT_a|dc zpUXq7u)2byhGz` z5x1}aAIGj|eL)}$X`eyjR3;BepJ`E=U*QZXBF^BgMe5y|7b&r0VQq?QqRt0DC7JV9 zluz`&+nk|hjAiIwz5JzrcX$y8F!*G<9P!PkAjv!Tk3#E1=YoCIKq^wz{1cQXok?Ho z@}o9NufaIEpO%9=c|=SLIYjRB8DvYCplq%7mHs3}T=(P2-`lG3ip!tEzTY3|uq*|P9WcI*9vm4=!>m`6tsCiMRuikiPB3aR z`qvn~fSVGv@;L^sGyj``FLDf>XH`JQ^=C3%Vmc2!b^UW8hJe>p1!ZCSW!1Y_mm
eUbwd&{0$>-$w{7eB6(>SBs#nVlGr7w|68uleKq- zP1d`R`yn9}oepd?RFX|o_r0T&4`)1o7LySkJGvFT#wlr=u&kaTC|>_0d9{r-u7BKm zHS0f9NJdJ^^ki;lil=OMZC$NX_~zz5d&yLr@3+q2mDv$T4AJv42Sm6RJJUyqDPX`* z?m&`jjN8l|BN{Y4#`OmZKQ*IO^3}+S9vU5W_h>zw8!Rt&Y+Qz7&0p|rEK9)Q^?MsD zE1KpbpQWdephu62$%l@2#Jr7`oi`4!yAkDQtwIRQx!9OlVtjVJ!_nhMV@!leVG`FG z`->-wFDwad+RFpCgHI$}&ZvE2p7m+?ZX6)1k`?WHh7jSgSyjD0A`3fEihxoHtL~)! zh`0=pj1?eo54P;5<-cu?@Hh zeXH~>P9MU^)V9aYP;q+vsD2TSZ5J>j5EHzenY}p(8kFpTUjQMshfENvgRY!OIVr5Q z@$JO!1&ydtnq_igKglh!aIf&~qistr?6XDjn^*-Y)8qbPmAlJ=-`v4Ri*YV37`2mB z0q2eUp~mdAAEYkSN#?c7gN+^Ig-Au;8m2${8zs+?^{1GOO9+B3YgBkQt>{JpoP^M*+Ew_ ziZdNQ+IvWxwm&z%SFHRxvZ6^ZW-~_Lp{6G++@bxgF~i@rPc+bF_rjh5Lg#m1`x5%3 z#B55>D2BN-tIDR2vX>G{i)x0`w^AYd2KxTJ?6+_IMtI4FzMJUzUrjgLOOC~2WF*P-$T7We9uUm{|n!Iy{lfSl{``8a+Y zlPyz@u*Vwu0D5`{9^xNBR$5}B=`g34pX81{kKQ+LKG-bo^s(t*%{nSYGTSCs=F=9P)K!-D4E?U^xf4RJw|#9hQvlm;Sox$!NE@~mTiJ|E zw5!A(**R_09zY9U$m}S@1>}0Q-URK$Nxi*&*$MdDuc31~YNBIb(^t`xSYTIjM?(&r z559konnlBNaaLplZUU;)h4l#>vF21>8-{oSW=p0n%~;NZ>gSX~b61@=9gZi0kzMtW zp8kwGeCQYy-sE1H8rRsM#%{Z&UTxAaG_rJiXUpJy!F~3^1p!G6$zN|c%OFOJ%@-w} zv4}kJKlk2)JFXGU?S%NI5O1)pZ0? z4}>~)vf+?)GPyg+bzA7cGaxUTDPX`U4II|!xL{GdEDVey9T&En^_Hr{_|N>@dp+yK zK4W<%^7FjBQv0KHP zMM|G%QGu7sN5@-6bsszRZKn?tYGeQzseubOk^3fco#%RAmP=z!<`zC?Qm;qPuBI#c zVt+zlKiPIP=J9N8TIW5SpUo}J{k?j{7$5nTopNsaY|7^^IuM^fo?veTM0X9LKa3gr zUEnEa`YHtY6tnWuKEj_YfL39{y#x`YF$CoOT9R|^Pj+^@qmR$4%EVA$B?Y842Y@0R z*=Q!c$n`D({jc^{u=0>HcaMwf{#$gw^?4SVq*6GO3R6Y8=q+=*td>^S@&S8NGf7Sg zG26+Hf~*!2M7>`_n>NqH6Ju$g>nT|3VXD_ZL21Ppfj$`4hjn@^``%;TeBi(VNN~Ro|TozSMp6 zu+WuFnIU&(nng(kzn6}rvpR0T!4~-;nb_C?R&XTIh$vy(FpIq6J6*E-9P)}jPI9Is z#rD5yn&$YLq6d-fD`2+3HcE&}vdc`A3-)`XH0L)0AD^GRZ50*!K<#8_rzrA^gakC( zUWAKF1L0#0ID_rNLtnErZiKv0s@y1Ub7&onJ>qVIb302<;8cI!d!TXcusXK|Y2~;t z%O%7mW+KMr%@s+~zNE&$Dc~-!w6&EZ6!VF>eC8Q20AR#XAdiyU;Lc#!`zs^cIEj~) z1(_Tpu$)7;W?4Dblj(BGDa-t z4A_t>_y>hgL#SWZ)jM3?kJza^|1OUP+0DHqz;RGV%OGEe5!pLm$BBy6I0UnxmRov0 za6hJ(Fjc`92vHzP{p^Yul_LoIEb%7 zj~bQ1HHU4d+mcOY{D(2rKRM_0rEC^X^?Wz(w>2Xi$t`v5zIyZCBx(rOVU=mzg_x^z z8FLjGo<7|_XayK;_C<8Ht*s}2B$*ky>7}%eH^m+)_sS*ZI z`z#u?TC}?mU6K=$)KMc6;mspLrrfc_nko)T3JKJ;LD+ zdH7^saDy6`J+sPaKRw2IGj+7CbsV2L(+w;ymX^4{is1mPNQHlUZ%=0i-{z+j?pqi9 z%n_Yen@D8&kp1+4XI5rfTB&PFr}y9-B+;FtyHc z3d?2;^sIEq=((cS>rcQq3|J~2sf9h~oYf5~Y z&(4l6S1QFk+)r|XZ;OxV*Y-_Otan53&i!SE%3&63AH(%?=M14^*EES>OK`(Z_p~OP zmxqVMg?;L5m{wXxiFF{kqodBmvr~a>jD`NFndY(A*_PUkahN~~+$)3e)kutv6HmwR zrIrLl({(ewowYR0I9J%uP^cYkX`>J79ZSyE>Vsqfnv#2;+4be+VbLyk27XtBT-g3z z$n_G}#GH@Aa}Oud4UDe-S>$XPJ{XYdKr)@RB>9fy^k~lJPx%tU$~# zKGZ*~b4xWW_n$nyn3WfYVO>%>aW{OYYd+z_%QZPBL%)yw|eLj9)~ z2_Ydmb>p!ka-tNJWNS$>J@~fLCKIY=)Sa)Oq9aa2^ZSTl{BAeFf)%Cn_( zfCTCStGC`5SS{PFr@volsS?xJXkfayGlU=X{{2=tZ&7gF%G&nY&TiVrJFlFCG;MR~ zpNSTe3!@`friOc4#@ba*1qOppeh~>^CFn)!DLaB#p#esl8!OM3E7agN&VbkpYbvFY z^2{W_qr^F?fg?or*LptjI|fxn6~m9&(VCnx3g@(?bAv;b+hcDQoPs`n5s%uGRgzBg zCY7$N-_WyGM z=8{O+t|*s6zlu9-3V|>UDF_BuH1`3d4=6KP0E$#zQ~Gz~F<@GUxI^dt{*j;s3r?%6 zYKvfEwry%o3E*I7EoDkQzo@?9R?xF_F*qL7@}(n9tzOP2r*HP|-Y8d*{3+(iuTS`p zgJ>UK1%z9n0qk>V6?Km0eR>Z)?{?Y5+ypd^p#A>IbWftPltFH1Dp12N@tDhA^3P0l z7EbHzHKpPyR)0skXxUuwLJE6%#Av&=9&RDf;pHQy=LHM!Zx&ggtXKeaGL;!~Nhl9~FH3b*8qKcV@u=f5vrV#ikvTYuX)% z9ZVc+ZV*tWiL|CBK;%Ud_32%e5&({b2G!<{KyLd?%!}=Od%_YBz{Jss8S7;U z2H1f>3k*v>zG;%q0ES){;N`H@L55Xzs@8+CD6ghzzjU1t;w|ARBlZ!A?ef4=IQP1W5mom&B*ev3jkoQr_(_JTpwY zrlgg296As(S%#?M_a;bD4n+tyO89si!d(MbYrgfAQdmB5D9!sDx=ay5y0MXYLFvcn zGS&O%l6CH!$~1~mQQ*HbtbqY`*;(q!PfM%jrI=Xo=vTGGYE*R@JIZtL>_QbG34%L3U^+bEV8R=8zTP8Jl*yiAzKmu@x8C))srr(Bi{rr*iWW_cM*`Fy9 zI(neOc?SgMaqs$)my;?hN9N00u%h09AW+xVfr6D%R8f$#aTbO!&+SKJNJth?uNSD( zQ?%3=J6YZpX~t8JHGPiT^s2e(=Uu8`5|&8c#RZ{n%hFz6HavV+2A#<%uC~!pT=5-PniO@RjhY=QY%?v)zz89 zqNn*}y7GoTISo&mjs`(HN`BXdWF!}<#lIomH(qkFQ$nxaw(2|LN#IrzWt%fI;uIUJ zz9~@=6_eIqFRFxiYQCdeGTSODs7!BgVt5*O2;`e#3E8*(Cg<1_Da;qkk20VgQ2v6E zJxthghOq`%sEiD=;p3cT3le_tMWlMWxq+r$o9d~^$gI{_B5x;?;)t1hT95il0VcEd z4B4}1Z)lX%)mc59k)w2gg0bFqY*{I>s%m(aY~Kjy*v&=th|WTohS#_LnM;$$KEH&j2%A`zQ8@A~?u4A_~BqrX-~ zsj)RrKYBzwVM`=V8u)uyEe0iU485|Tp!#lrHp=Iy*_SLCK6&C0)^^i|T3xE7=d>@c z9ATzLR6SxUU+_*q`;i)Job%CacAO|Kr~sof;H9DrM&bKvJ4aJCU3? z=8bzuP5BgF@P5!@#2q)$=eiB3Y~$X+W_5{5o04~Ht{GMpV+Gut&{7oXoEDPTYtcncF1esfXGmgVNOfF)D&O5~isFY-heW2} za-p9chbBNeA#=fMd=bR`eqvV3~xJ;xpOGzSkyFdRbkpk4+ zYw9`rb~w@U&9uMcG!(`C-cCB``T04-cL}mGSDuzB*Lr)>2p+>Gp@dO-kVgA@wpC5ZeugTJN2pV=fi`f> z$^jOJA86cYvk>2dFD@=otllc8w^X&%y4n0SJ(SvcwD1XTjLA^9g#QJ2-*1==H|ur} zAeYzAt{yxfsXP{17-M=3wb^X&WJFR@6GjdBT!GO%2if_F3J{A0<>QhtG#$`CUuvwW zt7~Xwz+2mG!iBJeGysAYx)!3Dp(tcfX68p-@LiUF^fq6>?rQ#o$ihD;=lP@Tr?8@p z^`E~v4H)Lzi%Jx4F^~v9l9mShsD^}dW|lsBSYr@&RIgF$rX%GEbxf6cmqfy7=3Cw} zZ$v;F;;!b@p z-ks`0rIA*dtrzr{XT@X8T*E`EW_1qD`iScHQU^-wpAsOEf_>X={wy6h)Exut+b;_Cmx46PtyiUuIQu3m(a-n>EGU@D*(D&MCW*VS%MjK_eTjc7?}MYqdnRL+Mq z1LFI`%7%u9^tVc+3F<{O(d0DFn?qLUJ4PcBJpJ;GPx2Jz!`S=z{juyGSG`VCa3St< z&+fYr1r-v)A}88=G(rHP3A2L~k?_xk+5?uKn;z|x5_sPGFs^>iPJc%XR@5IeM9i-3 zOBHV1%=T0w{oH)x&>L(H)(St`@wRY+SH!q5A~sG7%=*Cy-d%=Kydst16#9?Lf*pA8 z!!YOcH-59x9}p|``={i4vrXg~ciP)4h^zV4lFKBY1CXrK5KsS`hycpwj127;pWm-vgX(AtRvWhQO452GkZhs9CgC;EXRYVp4KW%Y-Iye>yg+m8MzXyM_dx z_e6kmo?Ld_4Bt6uIatGL(3NX6b~Gj{-B90deQFun*Z8}{b@iCpNU3?L$DjY4h)wW0 zagKPs-^H%Y-AFZ^VS*tg4G~OdetuqmJ$D@E3jbQ*_*hf=0O+=M1-mJ!DYF9T{#z4> z3YvZ|wc5Ph>OFjv4pRMe$Gx`BY{{Vs+n!kGA#dDr(=qG+3S?MQl)KUvm8dS!0y3`) zS|5UB`FqZU@53P0{`ysNWj6w&w2i`!G=c$OZ)hx$${0rjmKf`P81(N z-nLb(OHVd-oO)kuy&;Z;#bFrLQ_KZ6)9*+SeCF&faNqsG)Q-fjOu^_*qdu182O&ot zERtfL^-QUt1@RSC*|NWu=imm|v%te@&;A>OaMK<$ke zFP9?&<>oiDH3kzf@Iv&^ouCm!l9}z)?(^KB3etx}6T5 z7G86Gn$hdG#nNj0!{SYhrHhM!t`!W9%=sy9^Vj#jr}*I*S3Ni}uKn(E$B3c&q)!Pt zI+$~}qF_rll>MLGAR0S)zq`5vi|IFVzaT=6~_rXGXIf7CBJjB6(Twk1(HG7T42DrG=#zvdhBj>4iTjf1hv!cqo=RGq&?zRbGgLYW>jg?<#{7M*eHN zq}+r5gdBG__MdsP2_7G6;VVQ0FZwIQG-{6=LnP|&e^pmY)?2Q;>p68p)t)rpBcW1ZE>H@q)- z%u*Cz8Jap7Hu!0znb1hIWl)fwqnG;F=yZ!BOS5bOjt8(EhD~-?>d;NKkiboDy8VM^ zngDaB<5*Sps}|MBQhe3E78L-hsd`t0iWMy4j1x4C#s$b9GLm))@DNO#wJnmEYPo-5 zK`E~*@w4jzf}JB((SccNh#wwSPEV>oI5Sn{eR24s!fh_)0MH2reZftcm^(bmmuREr zNaMqzeVKcf=izXbP>RKht%!saB-`6Ji%kv(M?FPX-c}l*ruCG{Jt2GVv4g{yM7pd@ zV$?#T^X==%_x)-eTN_7D$w)); z#6ylk>kY-?wgX@Y3WGlHkkMN+pZI>gcmI$62$E6!8PsuR&nUny4Sy+vL z#2BvRGK!$Yf$#Nd}4_vDOC zb76pj4XU43gtXt-5&f<4UWcVcSd5?WJedJ++Pf#>@$Pg-yR2MtcJ?*K1>wCjTW(v8 zTNWD`E86gBp*udm8U!dc^r9y0ejbhV1I{5jMQR2RwC7^!dqQNr{hZrMZn^FOyg6{H zD$69Ch=B%H7E@*o|IGT*Khj?=R;?qKy>-mDP~C_&IMBSDD( z)WBt_2Kj?`BzYweGxC)DGxhP3w-k_JHV?jql*JB?jAZew1MaM%#I*AodQqmyfOlEk z%aHq8D6sFieW;mM4^A z1n86VvXA8y;}hX8*gTulI5};5mg=TJa)6p_KH2732L`Ou#h|W8tw^zI0*=eP$V)nM>xUrfd$jrT8 z-M7VW*=OXLK{0zi2|<-7-qy=)97CfT*T}5)z`B$Xq6h0~O;aXw&TrY+sC{O*!_wuY zzkLU~F4Z#l4~quRf`6Ogl;9UFc^0=k1hnDkW~`D|sprA0Nzim5FR1%$S9@o^G~?z? zZkOxDVKXWPDQoNjyN}~A|Ni_2@QbbkKA8lh72^I=d;tB+)pr55Pc_b7pxD!z1|85p$}IXQ&a$=Z9pJbui?W# z)QFDjoW&okv3` zl;6=5|2=7m2JZ{-RgJ0pMW9B#-QdMf9R3%hob;=7Wg?8vaB{069z`w7QgEPo*(`|+&!FWW)KSX(Yc4*f$AR;BpK+BIqZYy0E{o3+zy`uNGdr_?yR^U zqCC0uyxJDQyUwZPYKaw&W<{Ksb}wWbMKSu~XX^6U|zf{;cjblvq- zO(f9Y4pcmd{>K~n_iY@=z{JGSDoc?Gr$`fao|0i~dZ-b>Hv@(0SR`j;T)1Ba65!^_ z0~}WA$cUVPt*>4|m|K!7R+j_pa-ae*3FNkbf)BebU9-MR=*U3Ok{=&)0@$&L%PW7N z`7KDqY7^|EtmcIesS5Cb3lL*$f*Je4Qe9~Yc< zA3UBh0akuLWIuOa90*xBH?uMFVd`9v{{U$%#RfT}!Cc1W-mezhPd?0{4%mYRb~iIl zQ6ZJxCTut*bO27sHM{NRW@CE#F3>Nlak_|!>+!=w;DH{lJ)q~? z6czBkWPw=%VAcR3BB?PwgQ8Xb@&B@{9390R7FTjQbBdSD_=CNAar2rnD>k>Nze>Qh zpC%4}j)&tIg*CSUJUewfmabzKL`006y$nkL_TQiP4-YS930N%GywPIXX%SXdHr2+B zX6yi{@ZTwj76GRaWXt7VuO3RS+psS=-=d}n{ZRy5&VPK~Bs+5bW4+V*`ct5p()4$b zc@WU_<9OEklqFgZIP|86Px~`73XpMO$>{~i>cTz3sPFae*yXH$hfSC7^=cZ;ncmsb zmae~q&tfWOiYr|SfG$6iSt`U#Lz8#j`IukGC{bnz{UgXWJ-qevIzO{7NY?24i(c@q zU%NIk5=j9zjHRmpKHtq&2EVG`mwdj=nS=_#aUlP9cn@93XOEo*yt~vxXP5B%8ZHxuZ3Gt&bM-Rpa@IKsBw^Amo} zYwDq%3;>m|geV#PP7%li0>%|JsCP|0El*d=tdT7w4bB?CUm>voHf_}Y>2euw*25i1 z0P3cx0(i<+QwiyIn#i!owvgNdkgngnSi14bIpzQ}#cE@>D$L4!B=FBQ_W-=aiuih8 z^dxlC>whPs{%=R0shRB&cw$c`0|5wysau8U6M8AIwgtrPfH(>j@)6k5^%3x_{P6{v zAwLDU$REIH;H-SNoQZUWr?eY)l}r~AiJfgeWiczn$vp=kz@H&C z`3k$6lUWZrT1gu0m%jho07qaHII+7E(f_yCytiUciVBM|YGtgI9}*rDLc$Kj8XRD6 z6>+5x{^7zL58&ZK&SVwIm7-55D9DY$475_9df*0=R^#)2Mf(dF?3K3vH-rXnOibVt z6qF+JCvmkryDEJae6qNIm}LM2p7l@R^Tj`T|87?P6w?yGZSltRqRaK)sNqbf+_kqF zf?ugBt4|Guf4aW}kQx6Zmn)#v{a9paXn>%5Kz_l_8}1EK14b?N@xL1&FR)cGCF^1G zmF!4KbVMv5M2Kck`QMnrKFvgO^NPx|7uecN{cm8KH(vF-zwVx%_G}jqT64w2NhSFA zFPyu5uX42JpX&j*eQ>9e5!8zvmiFTmJcUisNuhjeM0>Q*GH0guBt zaN2BzFSw-q5UW3X`V%e3tO{*@5a08X+51tHO<#4rlrr-5X4n26u4&%JT;a{6>i5W0 zi(H_x%?b#9&WHgUom^8oc;l4V6Ju&2n}UB=`v6FZC%-ssO=He_7}J-)<-~(|fupMp zODhQ7HVo-nSw(K{qf2R>2%R7I%7@%@ZC;vlZn>vvu&^*M`rKJB@1@X733d?)2k&>% ziaOOe7J6~xA;3}cg6h2}{`b^dzE7=ha7Ck?vMk->jc zfLB~pxx&O^+jMCig`%LCoh%KPT=#Hw)f3=Y zaMhaA0_KiQTjX_^(11WV5t@k%yBW#33499<}0Vax(W=kNnA#yj=^D?&*z7 zzT$kq@{bWMeIef^*r47kFLoah4_w#O6ljY)n?pjN!H>Y%dv;Cv(3ulG?!cXwwF zk}uWNhmP#rP<2&*Mej@f5M;Cx~Nj6!@SpJcr@%MCn$N^h1V9gRJI*=r6_G5-cO z-WCdqL}K6t3f16L+^BuWscuRQNg*URau?k)bZ~DgD;~{MijI{mOyUu*aQjgm((atc z`wY0Ffh|ToWj?+6-N<3Lk9BZlq)&L8W|4@1-Lyt{*s zz0X))M2q+ieBi#s52K*j)oWA)UJBS}QQKl__GsP=*vn=~n*V~1h*NUUx14o(5 z$@lDkAHI{9FTZAO|5N}EnZlXn;o5^t8uT}^n}GRdsz**@&1r^PZc+d`cvt+*yU8gfV;vq>^H#X&jQ$NVd2=>>)&R- zg@VOX($dJEJyPeG zHgk7>=>Eb;@O|0G|M4}6qUr*(>jFDMobxK3&-|OI9yf$Nh-iCN-1h3to#~sEF=KUda^C2l?Xq*_r$D~x6-Ekt z*lgJ3+X^-r92!#%zDvfK2VdW^tm&-jWlj9;jNG4*LIj)Q;y5Jf1YY43EYlA3{%^rR z^qvvviQh_{Z7jbgZ<2AEWa{$ouGp-Dc<3}3Y(%_IcmjJPBSJcD(A?Y_CN?Ennjh?GW787l7Yj(jnFd=I!T<1d=#kuyG`CGjIe zeI8Vf&w!9=)yXNVYTWa8Xk~lM#Gu`PL6coa%#CR1|Do=!qpIq@{o%(z6r`jZK|+x3 zR#NFkKtQ^?TLGm(M7pFKq?;q%&7tAY-Cc+CTkzcPy}vQ;`}aHEcMZqzaMUA}Ln5Zazo7PG=fQ*9-I+>K zjz5s1qEnxN*RS&!)QWf=H*7)6A{t$nqQu04CVECjug(GO$VkO!A3kvPbif8(4t_rX z;oxmt&{h+l@m33Eaj^){!g!%JIX*rt%OT*|awomY!v}iWzhZR>uoHg_P0Ymo#}|;8 zXk(CZbJO7WkV!Ph|CIRL`w04J3j6i7tB3b-H~ZGn<gt> zv_U=>az3l8@_e0{TBE5lNE3$TtbGrod~Gc^sIgEtxYk3cQ6Z2mNv*f$?gmR=3qY zdbcR@^eJ>lSdCeuikG_I{W*#YH-3qP^{w~(qbuhvSanoLc}BhwGDdRTX=e{M?egeI z`M?ae`Z`mx76#!^rFulwmJ;D4q`cYG6A`6>JJ#LL;Ci+?i2Xvjf<$qa!)WE#2hmd$ z)L83;YFll4sqcm!WP427e3jlui(3th`;E(64L82fZD-I0BfqzK@1e4MXGceObS@4` zNbt#*1Jf0|S^_k)sU=G8@E#sd+nJjw@|+wR!sqQVy~!lW+JX%Nf*?M&JzR9IIxqA! zAFlyEB1^dWIy!HPt#DZ|;#OUC?qYktm$_@E$?OA?Q;)@C2C7aNm*q)VzO|OX{`OAc z$w@`#+>9DJ0T<{Vz9m4M$Z%^w$@@5?rG0BUThcW|%InGL0$RkA%f}i_c8<0@QJ%&# zAoE+E%&EdiSy|X3(_6Z>K$0a=Y9csm%$tx4>>%E4wAV) z9$reCKZid*KSCv%wh+Tc&}hORMP767&izP;PtaBPY+QMns$s zz4YUB{)0BSY2+i zfBR<$)5ixQn^@g5*Dj4{+U$6p<-yQ`@*wT!TWRn4%Z2rREsQSh5{W%|0Y_0gMLQW! zrIo{v&$66%1+PX6Au!L&lm!Z1RsNBYvZhYd^a)+Z-n8CL5e7qofr>Q>u@K0K7r!hHmf(=T_ z14-e6O)l$O*ow&NZ&KVwNe5jv*c70?x1zL_+e2Cmn^5 zEHeTR{M~E4;p(+xLcu&|DuLqQ-ntK=J+xwDCf)0*3piVY5P-3s?7RS2c5< zfFQp z68gb9)>>1*VK_o5&J#N3|BGFkn^B|ca^J+-r%C(0hlcA)+oGVP^ZC|fj%-Mkq#TJx zm0OU2bN6?Mj?V$3Jojj{lD+$g7OW{U?kB}+UPdSebhQn#;uMZwDCU9ThcBV#O&dj*Kb%hDKfTZJhC2w9Y6qF!z zb#!i11g}q+oh=?ddNl6|H$p}Z0I~(41s#N$ET-?xtI^qIFXUBTUQ-EQmKekc)4mW)!J%Q@S=antto^<2r3oH)=mW`>ns z|EYV?a1POhT|)xko))e+uZs^J{1ZDeV#Ki509X6gTjchhpB&Pm$xBzWnhJprnsVIS z>}m}{4$HMxzc=Rp{P8DT&#KPM=mYiXs;pLp+3YJ7hSX3Y$CDv+r)QNTn*P?-<>}s@imBZeQg8(vwW300qiC2? zJh-Cbt+ZWEO})hn3e5L`k^XOI5!@c1>*o9oqbx;MyqLI?lR*Jg&}R6$|GYox_;l8% zB!c8>nl>lgx?o`=I6sGiGQDi6Z_2@BAjRi^VgzJa=!?fbfV+b>Wxe&Fy*;<(bV6i` z6P72?7^?�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

MDRxo}jku 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�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�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

xV82<16i=&e> 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 m^()qYHG;w56 z<7b6mB85(!x58Nt6(>7Cd#Of?QZvO3kePIx+8;=b*j%>TP2Lw#=J$&oYCjD`f`+UhxkmCcDsVt!-Hr{iN#8T2N)zV}uok%=!APJ%80T2qq{tT44^ zTGHV}Dvvfocgy=0_WFBYXv)?DOxKf5!vzGp09f5%DddquvL#pI6{q*M6FO9Fid0$k z0=l^&QKs^A)Q|~|zCz)1A*B6<$K$xcro%sYP|@Qoi?8*SsqT%eOfEX=x)Py@uB7|L~phplj%C$t97uHgnY{r&_C5Ya3`irHFMj z2xdAeEaJ=q$pjV`zm~<*(m6b#O7S>9H?%~Dshr_m&*awj;ifLDAMCDzzT_JpDo`=k ztL6EcPJJ?0`>gpCN9{wX)ETlbNh8O@VD+O*szTeQxIy?2JzYtMsh-2LcUDAR%$;>f zEq7LFZDR7q%M*p}7w_1J)I5(4XsJuoV7Hi#oZ_=WUlZAGSpa z=VT{!ll=*@_7GtS3hZ*mbmC%qw#p6D4*}c(ZTej>s*>9F4t#u)>yLv?=(n-h3`}ua z+|DGQjUwKJbhz_*OH~fwzbNXE&(`~NPzYIr{&kq(d{N2PO^ee7cZ>rXlVd7EpxT7{2Sn4(_djinB?5Fp$S#ZbAh!;UF zi++KV7wUoZw~>3teM$*3C~Ue<4RO?w*xk`e*xu~Zoe*(G1hbwku7MMCG9-vIKB<2UZl@ zz--^c>x0Gv5ffByYS9?ovJNVV4NTOn4k06@9U8Z8bYn3Rkcz5nz6|T%Y1k?dk8o=W z-G-mxXY4=reQ|42a}dq^v4PKXu=@hjlF!?G-|FIS14UzTVydLvaR;URA3R`mO-q(r z8;8^b>u7ygni!1%{fL0+^<6d-r?c!1k+)PNJM&3Bey9St`a>k)8PgMp+xc)-+ue+3 z&SYXJw)2x|UZ2*?bh&1WlXV`p38JbqAk2pKLoI^6M^kodL3iBWE1y9c8n|K9^_(VD zmJH`!f*o5Ugo-?*S2oY{pN0P7q}S^U9OH&D)zs+h;FG<%RMVMKqXT) zz8dK{R6J7bJ7uC$o2XpI+RqP)nce7n^m50)6#33v>x`Rm4`0bc4-jh&<3Z8u;o*QO z)BF@_|B{tlYryA5G?#0W7t{KZ?UC_VF;(j??>olnkuG*k2l;Tm?T-qi5%L}(1} zu?y9n*R){-%Iipv6sDkMY-AMWEb|@4V2juCc7kJU$?%uTG_cB{PpPBiH^vxvIJKkC zQD4m`sLB_(Z2A8wo3!!C^jN;cbT3&2JRk?WR{o5HbqzyT#T7`=)KOYSJ+I8Z8kD7^ z)!Y%8Z+aVVt$W!t6UJSuQcXpx7tR88{?=$bO!_ml`~cnx1V*2l$nKkqw;WgjlP^p6 zvL4ppMStoz?76+}`?$?Rpj59?2XIm?(d0pC_4jd+v0 z&LQ*ENm`^!f)%?VQ%rBq7AZdnsmE2Ist=ZI<)8Gj+2%&-t#~_+I9Dg2minBf?^3~2It2moch#K`U=@&8u84>2N@gT z^Jswf*Q8EQ%hGbx*R9%RkxZ|-bD>-3@P$nTsa=>$`jRg}5pBYr5NAA$#qg#Sqv@~H zja4xqXAbLy^o8y&f^Oa-`Ca{IMFJ($odc~;vDrl|vHiSEY#tN+@VKbt#1EY^#f4cb zQSpgBGSqj%fTC5~8*=pUN||HX*bAhAzmJRa(z3d|wi~f)s_g;8Dx(etkF?0%DWroj z-rCB#_Xq2MqoZS54&WRUUe1aGro#30p20W!bo`TrWn|x+$8}!?qr;rs%;$&XuDmY> z0s&!rDaESB+K#v4VjoiBLX`b8zFSW9!&>Mg9dl$i35Jn2aO;Og$?@tryDsh}mt_a2 z2w1!qDP1Vu#;bv;!((xTwFa7_JvHPQVu&3e6!WtUvW88~VC_EpKY?u4fGvJd+ zy_=v-(!Se~t+5pvP0kM~wUIYz@pD~^*kYx~CuouBh+#L1kP^}8Qb;6LA8kkS_p@Y7 zV6wfbJxBJ&y9@S$_kqcvTeJ%P0>j=KD^GidJ~qtBKRx%Ac;$5%o$DDb-7UlePen7* zY+ysp?O6QZiJ$vNDV7s4>Z0M~HX_alx3vwlFXSF?yUIW5%5NjBb2BN^*$;U&%nxmS z1sLah;I^3Q-p+h9rVgihmLbbq^J!I%IUa;Gt_BMiJcOI8M(!22%kzFmEPLSIw)N8lnDP?|x^Z4}G zFC_>c=3GskZJVX5;mg@cX^JN5c74Vy7CKjUaMxycBPh3IKLyiQSu`o@>DR%2J*0JCot~tJP{pb%D@{Isf6p1$eX<3K@X$dA)u4a z!g-h^1?wdxlb)g;)`)s(iF2Z4#sv-REa^-}L-HX{!07l7)p#DYBG8hzsD#Fjv3(HY zc!DyM&VJudMoeeAH^;3`Yy(q~VY=4k?*mSjEf*|Fx6i4&q`TU6?}buLhhDOz^ZoEB zgyIx(nYD(ENG5k1QQM4|P&pLqR3`0%4$=lQL2Ey~wQF4%?}#r7T26l&XXuCY>XR0- z+M|=&H3P7=x)iKX&7?W6gD*kodf@B}&L-Q;zFU8~)|4?mV8s36q}#>?F_=oW0XNC` z=)n>p!Bpo%4q|%4n`r%GjSxg|b9;%PMgn#-37FD5cBb6i*g?(sQIw zCMqYpz77dI%CJ!XOp=o(JUk%=B1fBYY;GugVle81X~W4jRy{SVF*hyjE3T^FW|Up$Voa4W%ybG=Tnb9}eo z9EoLxwoRqvm>(T0H<1V49q+-OfFg-faXcXz9y;E|Z}VK*z@hzzy6v78 zhO}7~N%VoYsDAC>g$IAvb}RH{c&m6ZE4%Oqctp91Yg(OmZUt$Dnr=Qn=?v^0^{JAe z@ncr7IQdusFEBRyguAs3q9XFL&6LZpNIpg)zT)uYj2!;`gt_gV8}4<>g=N(wtUy^D zBj%cTi#WV6f00=Z3_i;rPfmh@J}N-UPKnnXHl4ZZnlR5BPiT+(ET;!imuqQl&5lEV zlJMP-X{bs6>n5)S^oj%RXHTiyKbum6z6x+bgS{A&9LUMrhK6 zbZoRds8nR$CBC$IHu$0*8?KSuw|;%XzzvBXm5=sba2P-2Y|{YxW%Q$NpL;(3 z0u)M8&->)+NmO|&`3mDTN;UmCQE}Yk)G~C-TZDuypNJwqQv_2hhPU=l;s>xt3}G8E1>ECxZ??` zuO(|c)m7Hz-`AO6fMm~#p8u5dar~#8kM)1b#@N}|*#A?`$HB(I`M=Kjnm|?lTBEbU z04V`2&|WfuDsH& zVC7if`d*vszxrN>C#VT$Do;S!0VaeGB4iQJ0DwF=0S5AD#KdqZga9Aa zGN2XW4Gp70g-3p=2gX2y#I(ha2O++?Rybe?j86js9DoLZhzJA`2@nK;L!kV~6K;uu z5J03$0az1a17K6G6Ya% z7(hGl_!X0`0~WxxgA66;`dJPVok9zAK-xcfxx3o~;_h(j*R>hauAUbU>yR!1`5;$&mlld$gt`ihCKy!0MzEsDWI(g zns);d_)V$yuG|Osc4-4ZKsUF0{bTfN1rqvA7EDXKA17y!hK^zeTo=YA0C4d@r2`=k z0uBJv_@N5n<`BC3_|MK%2E2CAv-f&r5YY9P48Vu}j^48-g-(GyggTXaX+KruM&I^E zUK7rY6xiJ*qz|D+qxU2!v_pXAHhw$$b9Q=Ta1W0mw>N+l#mw|uIwUz0xs?v);tEXh z?|0=9U&yok6j%_z9dIa!XQ&4Nf;qs@;zG)&x;HL8`PePVnfjYvU~W9}7NmhMJ6cg~e z0}L3@`}5<+)ZUtkigU8}EdPG{d7Pr0lsKdl>*^{v~Q5Fo{0#t!BP zTx;hy;3v2GC-dR=^iE&*$DZtWH}Gg12gk2e)9>_`-?EN)ssFMz$a}Ga@CID~)vp(< z?w58s{7X|yH3D^M`rf}W3gYgYNTT27W_k+p;1KBHJ5pHpI6nu08kS;U;U(VmXUNsR zIs+I5Y(Ah<$lq7{MpIz#_vq$za^dIabKmQBlAp5f^1Ff1Pydh}rd_|(f0huuF+}@& zgB#Be5MXT>^eWWV|M@in|KHfPz5;c`sl7RHTf2e75HF}h?K->xtb6~5>om~7tJw$Y zN9^0}{5KVM&~IEoz!!ksnZKYQK$4a3qhnBqL0m$%-#O2lb-74+qr1M zT5{d&`UW|5459+h60upeW#(pPGF0i3bI(D>GdG$mB)M}>eR8>8G(F{&FNfVHB#Crm-Z+^7wA-Jpe5TmH>vtpUZ(7@@< zSTF94#X`_qzioSQnx@rn;+?bWeBzS6(3YTW2FOuPX+JS|z1aD@ILz}bM$YK;WBQvH zPV0E<0Z-zbbA^IK*#uBdh08VR0N5d|BHv7JNRW^OGewDIubZQ>@lgIFRkhqtc7b;^ zfa??Z?pvY@tW`-;sPuaVmpNpwT8JL1SkAUNYwubmL2C8AHbzujdp#QEjX4b5I{~8+ z2InLuaa>*@$Bquc%i=A$G(<~N6_`@JED(I{V3Dmq?P+9+6cerK_gE+-*Bbp$vUo*Z z-uuj+ab?>g-N{@K6|K%uOZ+{Zsd=T}E@f9|U^0YFwPgQyNz~_Iod)suii=NI=MVMQ z&%fE{3NmLTWA8vqm=_62T@q)|eyS+vI5@_Wwh8%j^_YCsGsHPzU+{zP_yU}5P%5Kr zEHi+_`%(`@d8)xMI;VhvIfvbv#8@_9K`hv6AjFe;xdB|Gdi?7INO@%~$75PJNA$^e zCq|}aFCUcJKQw?|VkF<*S|jmq60pWQlr!Ru>d5a0UjTIhMTM-*X?w@9gn(+t#UtnR z7k+PJY#m;}e;ywldbCdr`98|cz7=V2%R-9@IQ#`LjA4&6mW6gRXP!Ua>m5vM4R{vQ z$-H{D@7mM2r;zuA_Juj<-L!9GG>75P zAq#w@J>yLjMc-1MYAU#RFix@>S)Fugu{YS+8waDEFpr}qZOWE>U%NbYdK9hYYGNea zHS{)$V)>>e+lW(j*vKN989}#TmB;UKiFuL9v=7JAM1KUSS=qd`pcQ2Vk(NYX_F9Xv z3|3jyKLs6CoiONMhStr!ia&|&$|%?@Qn^Wkq5?2j`9df(-~Xw=%N8sBtuyRev|p!b z$-K?vAF3pVNB;!y5EDTbS%1;&@`)?f<5z4oCb8|QbANI;Y|>P@Ao{-cZ}fO}bUtA5 zsC7267iQ3A($`9l@Ln+O%n4->6^iZ_%1>G&KvFD~74*9Jylm~sX)JyRCO=+xf5Wu^+4+8h!RmyO)6b;N!^EKCOFfFUw|(pbKHEE$dm*wex#hx6y^_6qa+tN|J54I z@0o3?_Mp(OOcsmU^E{oQL{^n5Y`((c~dg6y(1;|emr&O6LBJ_`*rx(>+ zO^VLf6#bdt_)FJk>W)?cAWGZK=|OyB&^YSCeW>{C#!uw3#U7(CmYc{*PsZBDaP~l9 zWFbYcy?q;UMvEWs62fs4TyrqrZ+z-BBK&Jw9|bi_$oJ?+=`nhT9X_#QQf=8BuDo7! zgzbt8#ePL%8)HDCY%h%t6G78DV68 z4reXN*N26OW|n_zT!OOs!;|pM+rv7*K+$=3z(cqh`KID1bGX4_2$${C!$#4@B;Eg# z@uqL`)Lv)C5-fuPO9Es}=oyLx`&*?J zH_&KvN&5o8bd1Uwv1x$bD_1BoLtU6xlvg|hT`p_8yV!o;XLKh1^8Gq6m2NzyvJGV( z-n9lC?Kw7Kp-HV`2p2y&xm00iZpBJg595^gga>tpY#gN+j#ltyerVv)0xC|yu&EM7 zyGabwRYbv|NH$UlR|4Fc=i2|%#vw}{>J67aQT^VtgN|@wsC{Ur(UV%q{jX-qtP0v8 z-F_fzYI6xaWMRlGHer7ScNOAOE3_jj$Tjt$xb~H4T#YqRu{9xl0A@!=5e2@tP}!|J8NQL_E;Io&fFxuH)9hAvBVT@3Q&SyI@9lQ&^IqM*?zO1VB+k7xps2lldB z(UkodRNo5(ZeM+PQa?cf}pT5_g>-Ag2O( zi19M)_}%1SZ89ozGr6gp2Og~DI}}r@o_mIKP<;+AZ@3SDF+iF|)a!eGoyOwq1|zu` zgxMIBr_s*a&PEgSqOpJP79$K#sdX3U`5b9pW<#aqkE)Tz#W480PXQuB;GYWZPn_h=in6Lbq$K};XYl{?8`W1y!f(XCA%|Q&wBb zmX(zLz0;468|U$JQ$7}3>Eg_nMB=_6MP7Z7+jR{y!7MGbill5HlTZvt()XiKuzW=} z*~73uBtgf%@y^Dd-BeI9J0U(#m_AYDg|YJl zMtti+5RTED4eU=|hOnb|vQ7f7qP=%EF<(@D3@OZ;%y1+5V)Z|HUH79n8SQEPWg^5q-{ny*~Dz46X7R~0c zmwr$cwRYJCpFpMevBICL@ljtV@RAyZUZGI-JkAr~C#`v;Ph9%;+40~v6Uh9hd3U3iLpW5!CZ^wsMJ`$FheOJh6o(Fz)4 zKC-?@)X`PiK7=Y%JBlj~$yyX6bu0nxkT~BHmSM7;j43Zeejxw$1N>?btc+{uprma% z%$xuuP*wZ93ATh$i&cgZld}W&(H~;dGTx{@9;IFg7!u7EH*3lDUsEH-?`h(fg0;6z z_ZQX0-yv64zXssY1Q1qCze*3pe_gy#a>pCKR&U1^>$?t3|0wm8-$m{|JWXdiLv(7# zgIZ%j)Bvv^PvDaf9EWGGgZF&0su{h==Pa%Kak{2{lvO#Lw>Y`koDSTMZ<2m!M%G`f zt_&^0Ca*RZ;y#Vm&`8)(FIlwg(J^Rxqmo=Xp2{((m?t23HvNaJHZ9r%vWf^FVM0@7 zY)5N?vXSyLWVf$ewQ1Vn0=3pwzGE&xs;+3q*y1na3$ZlVuxjC)QpaSOw%*<~ll97j zrNtgudbnG{ZPfk=9m!^357QtPz{LCivx5P7P~! z9G-Nq)`z3~{p%2x`jFAFp@8?Z(X~A^j!F$h&Xit%Tx7rOEr)iR1mNV+ui>e`F(b6_ z2%a13Yn9Pz%9*|NIb_t!79QsJDE(U?oEDN>tAY4(ziImo^zv1EDAaO6`Su!(B%emtioE_SLoQCmV{HlXFt!}<;j zl7Ax|9F0sJg2HI<6%eM^R!jMDofjsa|9j9>Qc=5l_;#s>;{+X!4vp-kuB-2r3`FD@ zKGP95Pp`hjdAgo20|$>NpBKRqcAl}nsURcOz)o_AgXm`+&T0gmDn?8VqqoG{-bi{i zUqu6HkuxK3oJ%d3na%#FU&=>BMWj#6gnYQ$GRJ!&7UEC|Mye)*1moe8_U#!6dR~ZT z(kfKEP9>ZWFN*s4{r zcRK@H;V9 z1oIZ;w>Ci~FNX`L$9GSLE?d5b z{##))EPQ+1SRZX!aQaCOs_=FSp^lbMdVE?DW9SQ7nD%^Em_;TO;N6aVc57@yl(Uk_t-=^b_Y~^20>O z-SvDl&|AtZei7|WZ$g!wYmGK6mBVhpunbfR1pJqVQNBoW@4tFm0jq-&RY>34uMd%p z!QzH?YuOaz3E1S|hjXbqEJ)x|T(TV22;$f0<{JD8AR{C zoJ%6{YynB$unoI{($W(|x@TANwVHAz#4SQFGkAx8m(?EWtXBOQ=YIL!SlyI6DCB25Eb?EF(>|5Zp}d9-*+Ix&w80 zDMmKF^$HNazwG3cVPofFS{B@d4rGZ-zHI%QAOfVj3(Y!wT_xXi7wxBX)^uH7b?b&Y zeXxQQ_i4~}u;9W=(GV?cWT{O}DNRy+YPhup?gr|d(90Cw`>F^nX39$%j?=b|uwwsb zh*XN1K0!}wF03x9I3#3I>ZJh^m=4;EL#h6b*5i|XSfgfHzJk}M_ZNqDX0Odf8Q1ulzl`&Zbf<0$pd{MlrLUYx-(U*7d(N5c%RE-Eywu-rNgP=<@$vKGW}Bz||5(LxFim6^rN08F zu~gaVYru4%th(<_=Ra;3 z8zlpRnPjZwFip~qgAuKbYQ`AD1&;gTXMtmx+x6b`?eNGA6>NPhtwHf2HCkP>;I2EG z36PIk+clB0Uqs1DBg?!Dw}}v0#+b59ZKgr$&}_YUxm5XWBbSN48N5-f88yK)aR(a_ zU8VY?#<^hx;Ep&;G3lYz5{qgi=&vzt`QM*O(os!m3z35AD)U^;KcXY%HmkCyYV~`c zq6_J&=(@a|voTQ}I%*g%s=EvmN7f$Z#Bd+-A)S8MvHn4(KE!q=?bX9C<)-dM#+AOxchz;1-WYLF9akJ5 z>m6;J3k^S8+pyx0&B(B8S7qNvThp46sG;w5V6O+5QT>!UQ@j-j z^YHRpa+=KCvj2-F{~mX{2aFHHJ6ft&ER4(?0fwwi&7wA4lQeg*W~>+g$C#astfe_t zq6v?@N|wP8?;xcE80>oRGHWrI%6JvZ89J~E2pJEIU8Lk@{d!vd?X34|ZgB)oD4qT# z6E*CD8@`!12pB10?W+v-N<;zH8kc&;H zhT&6=A#K>cIEq6s<2ATbRk|?G?%-5Zpd*L{7U}}xj+=uSRaV@QLYksy@V?j*lxq`9 z`Tbh{RuJsoycLRM4f=6LCRclFto|kAN`OV(#!WszvfjdU8uG3o%cT_7Nj#D6uZbM( zVZIRd(FQV#Xt0UyB6FiXT3RPw=keRg?AL{AkC?cOJ$MF$wOq!VTQsf)P*J^mk6tv! zp)M7h!%lw)j_8$22Os!`RI*VtX;s;&vjx%Z_#98+m*)4tp$*8sRZnM_p<`y+V(eD8LFdAeB~q59+#T;zc}`zsC3^%jLTV6mO7**5M7q5u#l z5H(GQnwyLEy<(ZfCo8@mr@YGGl7yr7wn_OVU3F?R>hViRC5HEz?G9+`1`PxI^`_{Yeu|NPA)^}k`D z2oW(^@R-))Tjq8hn`1S{3i7M)HYi8DxYC2;dlA|*EN@si@7jNV3`Ko`7i3~BL*KcZ zim@#WOoEU+n@jaVu8ZYrL(fohb@59rV=#_9NGwAiNT+&HV*7aofEgEqu|#u(M%QK{L)lVt{aUqGKLHinPq{bR2bDpI=w8H*49n# zcGzjZYswy*J*CtE?Q@$F5qBWO$WVEq-fX%6f{R6*MXRq--&B98soNKb{I~5s`=*b# zQs&g?PvL=`y?Gvzt4Cwz*)sb8Sv1)$6_QQH2{@WpFEmXjrC$p=9EzAbXPnaKq$6Jv zDoOo5wsXb1O=_|4>X|I$BBDxA7Ax?ND+_oxhVy*mzKnicP#|NvE>5gh8!8mGM$gNh z1);iqrw8FPd!SLXZ$6W}!op$z4R!gSd}W@o=4;y_G1$ZW=mB`GP#(-FQ5iO2GlY6J zbUBOKhO@A*y{4lW=f8r=p9NF4QfPF5$n$TuqK9+kx&uMlEOgtGH%R4~*mK8qP1={C z8cb|tDZSF2+;cx()qCN81l6MWP@n^P~ZFs8nM%cj4j013W zo0mQ)=y~;>xzV7?&c+mcq8TkZJ)7w$oza@>(=kegBew;!)SNO3s+-h$1BC~q46J&; zIm65!z`^ZU+q8ODhuso8l7I&<8yT`nyvp~;FP}wQ_(h3Jde-NO>cP!(6}qJmt_=dU z__jGA>0@3%(A#8W+DoBGoV*tA`dp6Jy5{{?G!)&BSNzi>Q+>YQuP_lKQHbPetXz$s z@0th4q^FA3c?-B{Uv4B~&QB>(+r$@h3^Ql5#UUnw#7C+zPsjszp4BS|yGat}>Pr-~2m0=ylm7(GC!s)|a#p`C&E!R*{iOlV&u6XkpK6ibTfjOr=7)f}UN}EF zQQ{Im;}x^-HF_DS>-Rqyf&(o7mrKg58PjOKPva#PnbNsz;lHWl1-^a;lMK2#DL_rs ztc6aO{kS9-k*Y2`p-f{pxiTFfB-F+QQKyXH;>u?zW$XocbQ0}mpphQ*id66QY7z_< zOv{>su|u<>BWo> zJ!S8bw=66Y>_-yGSVOsTO1B#fH;nHG7u=w2OS>_`BPt;g1y?RzDKa8CUIJw0XP;y5 z1nE4AX5WH3PPr;gwj){?LLRM<2{coM3qoesH@cSw!&A0mbRgpsNsi}(`)i1WIOEFY z<+VAIq;!+jyH>fdAl_?<_x(gDE!aYEB2SvZLoNwGG+H|Pgk;KP$!U`_c0puTi%Tq+5XXH+8 z^FUk9+9=K$ZPiMHdSRu#N$6*W1k*LP4g~EvjFQLE@1RO4gE3>Gmbj`|`XU-@G+iTi zG0rqYArp)X0&Ev)#y+=Z;sRi_6l$9=z71K-#h(pOO=COI~bOXl^-(4Rz>qY}7AJ0^6LHgsw<| zglT(~s_EpHZkia`-qY70u(2A#2oJmn-q%5BJYk3ST=wxa@>!PXGeDSe(9SX{Pe8gy zP#FAhsElkD7bMy1P#bg5pJjZJ2Lu~~R}sZ7TXe2rVOTp$I&FxxTiDrY5n1*NFS@@b z_#cWUj12#&Xu`t4^xxHze?U1S^Z#A4`+tFQ&i@af99%{DdV#J$LfMjvU>KJ0DRHhy z^0xaGjX;2aL_jEYCo^CHq=;P*1W*vvav}POia<9iBEThqKgD_GH`{x)!`-wxo0oZZ ziu-Zq(S{+Z1us4ZcLS^@Ld?)JuM2@uKu%a#6eK_Z#DfsQ4~anoKTveww?nco%)uQ7 zC>H5cAAr-H119=$e6Wrq3k(ai%!>f93;+NXEqDhxSOCBeLgYzbps5ps0RB;A4G^m< zfC>xrUwn5wSHpJ$0WMB+ljh$Ibk%|bprZ1G_j>ChFamlLR~MQO0E@ALcoW1uj}`{< zG}i*bvGUJQy+(>tuc6M!$45_3ua^cLhJJlRFy0UAL0A_TK!*eD{u;6g*b4^6Vo3Lo zD8Ek(0Q40HIf>VYw~c=eY99p50*AE*Cr+>*kW#=!zk)pp+o#~x8^AyU7CRa4 zIe;LM1`BuB>5P#_Q3%}U0Q`dsZ+sc&Fl^+(2ys-4`0kh7zuf>dAqRJT03>e6NYeX} zB;Gb?>$rL|_-fubL(zmjar>`^FR;G;i#0Gam^=#*>hKs)Irba6r$h34&)HWWfFN>| z6A%Fa;10aE(-P;?J8}8hHsBAQ-@DZY`SqxG0(Jg~ z|91QRfJt$IWl8D$o&L7VsHxe+zBf8Z4{>(~EeJq?0tI-pulMzPwh4B`-%ds0 z%@Q#9YlrSG|7*4W_+N~jQ*fq1yRKu~wr$(C?M!Uvi|t7=v2EM7Ik9a!*|XQdMy>x~ z9roedRb6j&U(bErjG%viDuO{@Et%!n;#wR7z69ww!=Z$2oe)odI9L5kKX<6UD<^)U zkAAii3LsJ5c&EO3zx_ju8K8=04T0jC3AD06*ygzK^S*Jd!F~#L;M?#A29J6RX|%8u zpjoIO@iM*gg1V6U1^vJzkoVwQ7bT4B@P8bS<9BvbR}!Gm^l)Q^Ki}JS8;BBqqV~Wn zE@D0c{tb$Jq6Qf|S`U6395CX>EiMXvC<(0{>iSUd~^W^{z}Yg59>0_`Q9nkpK~59N%qxk8-yVTwk|I_$`92 z4)tE{SceaG@3oLEqW;poV=6tb8#$oC=3&fPMnnPQJsTbQE{*vy zQljgxe##54JCi6Ya_XGr<(gpu(U>8&2t7_(D{~)Ovwf`{3*GPRfVcCHWSqldI z&feQgctB=w?GsnfS%$Bbh3d;Tx$MyX!s?LuY$3sQ@`$o`EuZ-Zn1s!JBFf!Yu0dS% znL+Tth~8NC`Ay=4sd0`bVKf{B7MXW7}bnb{50(>zh+lR39=IN_(vf zMO9AAiRtvIxS=a$^o*C5I7&{qA|I0!(t0L6`|2)=7C_Ck<0=QX3lF`%MIqXw>3KH9sLqE=!yOd z$o4@@0$L<;ydCqsNAcJPKK~u2qZ!dS5Oi5Rq>xvskN3lYv zRUJ`t2C`%_ikp00-{q69IIbprk&}wOR2$F1)=YW=bg1bsuu8DW+${cY;B%#GOjgTV zkGBgMKA=%BwQCWiCWa0+14`Tkjg&41!b%g-$A{et^m3+`!H@mM`d`(Qrf|9}!x#PNf{tDobm*H<}!7|gq0J8*sPi9?kzE1qA(OK>H(W&{G>|#&jv!%yk8m#ItvC*z>hF32GJmXE& z5u8HZJX)u#`VTk7HnEKp~e9XK|F-8ik_ z06@YQ$C6B@%%#nw(e!3ojo$;9N^nlC&y_ z-cZ5_QsRB~qpzGcH&6D^Gu;{}ctk+XG{1CRui1@V1H*bCgW&3(IqNNB{wTkQQ{)P( zI?KC@SOd54I~|4qMf9uFzbhOc?eV(*syGD;$nCFH?^LxmS$QjZ;rm8!&yef{#_~Vb zurdlxXj;yrqsw*)H=3H=RW=o|pdwdXz2NpO{)}+`Rmr2@lpq{UVs>ClCg-dwn)<$8 zEZsm%6`rm8j2xqD!g5$+Z#^NEX&WiM+oW#bW^MnKS#MhA*|XP3P^ep)g)uT)G*css zkIZ~fp57A&6WiyKxPoj98#>Erlp2F;0AAahlGY6{JwLqCh%#U~?q6{|c}_Da`htacgB)9se-cYd($Fp?4Uby9t(Zdb zznE|!p43*4qg%qZp~HrLpTOr!wne$YuR@Cj4C`scy~odF2t!*aS`D9rN?m8B(%{wL z#iVSwBBLZA+smiYd(Y1-3p}pP@;sSQSD_u3 zN?#7)XaX#=pZ*pyWOv0W(_@9{;BZy{tr`>!eG|oMqZuwTxG}7~#4zgC+IuUxo$-vH zt-9;#Yh+(+HPEqZrJf;ZT5}|jdIhAw7N${c-yl+}FOHY9m0IOYbCS;%n4YJ?YGHyA zf&)HXg%x`9r2L#p)R^UJ-h%xf;)LaHN#~LN3^A|G3d4yE_~acmo;(mOdJ$__v_uT1 z%lJp#YJ{IyV9F#M$Sii>k-?sWD>&49Gck5?3#B=_^>%pYs7 z7OGLFA|4!cF6Q}NZOKscM{n}e{Y*NkDr)o3pM<^&NCPL5Xm*J7Li+ZP(2fPhVlQ)- zPuW_uVd#@^i16DNjMH$dKklDJu=&+H-S+j*V@i#(7NP3qRk?cwmh3oxFohLPwJc>&@Iq%w#H3I0q0C~%`rt5`8*$c`8tChAr8{8h3Br=L^SbGz2~+>JqNZ2DY9e;+7)-B4%2P;9nS3f7 zCgJY+s;s&)A757UIV<+jxZBWpfBqpSn`I+Yc0l4w8tV0L#(>g zZv*|ji~1UJ;IsF78l)Nb)b5tl;AXiH()x?kW%ae0!^hR3p4D!yZ0oQpquR{ZGv}KJ z`=e*|o^@|2cFWGfi2{mJpK)BBuS#VaTY3?ky+K86IfU-6zc9U%%waNgNz8N*1Jy&>kdeOEQs91Kl_aYcqVajcBcXoU69ieAy)ztk_m^@EPqZs z&X`UT41&NWM{88Ea~{>4L@&rratq81hpZ&hb_&tvQyj%&v?O_*TVn}O!$0~Wx%S8E znWT-B5jWE70tb9a;k0PX0z@r8%71*M#~;F&{%{Lx9nQsSfc~aG_PA0E$I{P(46Jh- zYd?_m43g{a)_dW?^jlHzE0`Nd?O`Au(0d&5nJ!UdoB*EGI;oOiu27U&oh5sG6q+#b z${Eln1L%h=JJXdW-2mUk>fS zyaYh7qNI8#FG6@=yZ-ofG=PQ`BWYfITA@<_=)Jtpv^V3{KD50Zz}S$Ztky>XU!`o! zxJ`U$P-4CVbj1l!SGV?v*l@;=HHUFAO|WIJx`ydF)EazQc`X^lzvmeYhG-^RN-mzu zGXAD>Y`xk`zUrQHkk9SslsbHzD)1V*1U% z%<>Ap*@d=uJck5S7*J+W{J|N9I!;LEY zPvT+i!<8jws%+f8{=C!MU009gjzJZYVZ0>DA?J>TH%Fs&fu;*V;6j&P6ADUeOEOH5 zcl|BQYK5nzir#f=JMI$W3gr$fJKpo9-vPx&=*-m>BXWxvYSz2POyFm3U8XwgEzNDP z5am&wh(#{hD_aQfznYh_!*^tdo$pBDpT)(HDyc}%2i#%npbLC{8XrzZ>=^E4as>Ct$L20|x0C|eca zd%*;3qZeP^D<{jp#AO_tjvPr{+T#aoXL-gSZ>Fze>DYTu;nEoVq|pZaeZy&2EWWAG z_<(FcB;+>~Ny&evEIHtd64h9TwHBY}KerBWg^Cv5P%q}JkaJnXN!5+pU#&skrph;< z;=VQV`RFgcyj-XN?UAPEzX_963>?8%gjj6bXB2<_s>-lz>^7ER9~JO?UBGQrxwq^L z0|r;L>OYlf9jGl2T1wk~qb%epFRc_^$dT8tr>l`OMPKqJCOKXFjpj(-i*}i`NHse| z;unx+dY=@7dJ3f8ou^|6T={7kpBHZmCmTZj+T_hl0ec8*7U!3=wimp6%zr}`tu#xf z5NXyHn3d@&iFYy5fOmt9J5k2;IFplK#QlT~1~E`L*+a05C;G9xGw>l9QCb7whinPx z0xJAA?!L8EKzLx=o{-`}TI%_xxgijtZ7y$LZ8P&G#-2Qv#4-!U8h7}`B#hnCq3?yi zklWd3h9{G06t+#!rn37qcuw1=$IxBJB4^Glmr_ZShTG1)UsHPPyo-am4STJboUjBu ze==NtFCmCtnNCV_!~ju*+-PHAYBjWw+6pQblyrmVZOV_@IO%Cxb_s8-RgaaxHW6bec!ifZ3<&$Yh#k(0avTRxeB~ z=pXB0;VS=m@Ksnlo~cd}j+I+|%+mr;g>}Zh$uMACjFhM3Db`%mp~*&mF^T&37uJ}! z37-{U@3l?Ba!O%_$SuI6z41g>9XJnjwnFIAQCq z%7u}Ey5hDlPu(rDypswKlDiG7t=mI%6TOd@tW1M$8{9$J!MEAg*2ACRw&Q(jS{5}V zz+GxQ40sm!{b`D{%Dh@J+YL{hyo97>Kmny%q{f`fz>om2xhtJBmsKderaP)e4aTA( zCWxvJX3XxIfbAX8sbzThEt<}Tf60w&5n`~`IG?Fyd<8g&A(x!cT}_}Rg)aQi-jU(f zQdqR+!7AgF<9|Ri->KM9l0m&YWORV&4~dZ$Ky-(m2{HG_)JjV7d&&Y`v!U0DUgQCw87+`_rkD*CpKH{UD2ohk-CvB51BK9oEFs^lI z*Aer>mP>PkY^CRmSG=ueI$!&5MF%<10}E$CJJ0V8$NGfu|$yFPp? z7nije&(lnTcVybe!aHGCFi7KwHJc+C->qimT1`X)D4u+cLf4G?tDBN0G=Hg}5b7p) zGX`c{MF)$XYr*J@qoig&EA!qgq*M|B=rBkWHgVy$vVC^0E~8u+68$R2-&Tb-7saZ- zd&~?YH&3%Hy>J`rPCgz}f8ySV#iMBGihVJWRskeG5hVKjHO}#WUw&`0Dg8$GX?Z*~ z&iXfgE3z}IzpVJCD!P3{`Ukrrtx&EcvHOYeQWTEb^ej=96#*-{5Rn9KWj{SxbCkv& zcYn*DKXvz15C8koDM%py{4T#9g4{Ans`serCWZb}n$P^nu?<8r-F~9kqJ(Xa7T>_F z(`!$*)8mX~6ikJByS^Tp)zmvfk3$4ot?EY9wjV)syEOlKr!47p4L@(=P!M{>Rjb?~ zkjl@ZO~Pt;`O5H_?e~m&NjFxbGz=eZyb25Nx-s?)lP2c*v|{($p6jtK9%Ix{4^(b# ztX!JN#qAs=w-pzUnt!_?+n4ZXdguz6aH?B_>hOpwd~y~o4a?xE234khy~W7=qtA02 z%3&$OuQk}U9BeNf+34XQ3Bv;Z8V$F8nqN9%0>v|^pfa3^Tq6y^nKIb%=2lp^j5V_Y z9LdI$9~B--_k(uHBu5!ZC+AN8q#1)7?8!lfmF^BP+gMR{MdYD^Sf!G#Y>ULz{8S=u zCaa0ibMOR-8mjE?uXrimV1s-ydsK`+F7^vls?)15tzm}@fLcBhcH-Do@_^oZJ6zsR ziaF1ia#Kfm9G)wq_UET8L0=(F?WhdQF&B8o8Q^ATtHf62((IXj^5z$qO)G)xO4ne( zuNH-(+FgFpyJSuD?q7xrsJ`4=8l1L%de%w0cPlRoo{H{GD<{EVyaJa?VK_m1zV)Q zNa6`DgeqQ>#8icpdFK{w<_DI;pQfg4ty!jniSLW|?2-TdgaPv?0g$q{!;w0^;XQr} z`0eL?<;f zz+oTO!mYJ3n$DT04*nTfca6Rs@X(DI*|Tl7lPzrz-CR4fA3)qQDQXQ(fVC71Z|?1} zx#;cBaAtfDxmk&(TR)E(WF&)$@8*_gp@xokm(ecK*x)&i(#1HCe28{wZG7p4ND;pj zN(yD@uxgkhcR-IP;L!+N$s3INx;tK5VL_h>(f3wSsQr_wQaaT3STnvFt?fvSdFBD7 zyki^P-22DOK8?s?`$EYH=xp2T&K6oq1G{|WJ*lf!e6rVrVq;KG$~pFF)5112mj^SQ zAN~C>H%KpL17ZFkWqX|S#EzeBCOh|H8*Kv%tu$t$43{YT1rqtAByvM~%W%h$GlhI| z>SpPAtlq8R&9&FXoFa`};41^9)lLq=zrfJ)O#^dQhd(*Rr-(4qTxj=EPY9_|A6o)O zA1cVt)pfo+je8s2_L?eo-sVNaf1O1vpP&+@JZZh!DM$DaYeif5$+wG*dOA%*kX9r> ze;2&NmxTmkLG|{P+DUeATeqTMW1ba_f^qG-^oWwXYd^ODy2v|;r_`i~V8R&b<4$;> zZu|v^nnO}ye+$<gHv~gY$Ga+yO}D=IJ^Q`* zy*&6{PIbKVIj=a%ymt}USpf+Q4K4_{Nr;L!0`*voX$(0J z5mJOS6(&_381l9&i6W)K-BtS=!APBV=i47d*uVNP=q?37E?$iFYc9|#fH z{|2IOcJk-O*}u^rJ!rs>GPuvEEr}KC7&>$f#5OMuDIl(af^h=$2?;1j`v)Wl@3*6z zz!*H38<@5+P%keeaPrY35YbeC|EOpXvXUiQA||xkCp*bbElZlNMv(TukIo`uLQ6c< z^p8>k1B+>QSL_kYbPMR;GB)ss$EBKDKVC&zshRN&V=9ZL6_dYX1Un#ki=V{^0)YY@ z76Ts+0YbtA8UnQ)eW~mUEM-0PNcc&`Weto^W!=O!3dRQ62W<{C`j+ZSAmW7jha5v+ zJ-=@H1nE)y{QhO$V?eZoZS$EDf7C|Xh&lXir|yxJ{^hlh2KoJfZdY%195ArFwFiyk za()7Twi$B^vLZiG(XRzje|RYv5nq5j@1P<;J;6c(f&B8yn1s+C_8(?hI*3kpI0Amg z)q~gin6drWhc_2{b=*HDfo6KocR@ZI(>U-%b)fzv74{n%F zZeD+oA-IL{`F}u|s@%kl(el7YPl29y<|q>cE0%+_MD?@3c`L#|jFX8V8;9%C!wn-a zsUAlW#c4$OiB>w4(EeT{T7JL6`&&&T!vvoW{GKx;#FWE8dNpK@>FYyCLxe&Rof0HQ zLMh}}`e&eSAjabVXP_p>!m|I8^#mCp0YU-+z9;LiA~1yp?RsT~X%6%JEa3=<$ApkH zRsl*&2LN$6f{oX*KbX4DKzp zuxBSvZaJN3<0cO6$nr2_>|#%N$%LG1yqmP+mFDJf6e^eTZ4K9cF7>GEZXc1E8sB$f zcAOhDm(C&2@*lx(_-HVru1fwjy)zh%p+iM`(ppnQIZP+pChe$qy$Rc~B~7VsxmvL- zz3nkv<3_)k5zelqt034c+FH=a$t{tdq_S^nyvJHLTDGkF_ey^4cO0eax@#TsR{3w> z5(9obMKzJ@r6E(c`wQTbtnaZg__e$RvhuuUP(byE;E9R z*jd(nqoecJZ`!>gm5(_DK$~6vXJ0XGVO&l0FG@{S@@sJ!cQya4OC#I27p;fLeWbr2 z&5%Dzl`*0%xsupl0m52KXOo-XMqa}3-xO@OU0Er1K3*NJnABPC1hc;j^>*h9J1Ohh zmj7y)^)sC1jcV*<$$Cv>7QURJ&!__E8EV3{_kazHBA((|LEJ)5WJfVM*dtl%ps)r_ zU5{F%e5<4EphjTqX^qeD2ntXM(dd>HAhHf%opeb>djVaQ;;@1eQ}5z<-hlUxt~XRF zq?0XuFKR6f1tr+Kdya*K)H9~DFzeEZ*M9+swi)9OR_Ny7@qbb~9_SbgER!4%OeZtE zA_d6UG6}%bN?v4h;M8q2Fx5B&DP^%jwz_S|hY^>%E}h-Le9U_)9&wW=app@YHkIFA zmDt~^bN-`N6~6F@8afc*ZXQd>>(A~xy5j4EqRgXtH0J1p_>g1l`yS@})dWTxDd(Au zi3)wqBE)mUvEdS#oG>4;f&ern z1|DJ$(}vR1z1e!9E&bR55hj*%ms~2!j<`{Upc<{YqVq?Evf_=^t)cl(-S9m4R$aKS z*79D1QdfVFtsYlFkag!CzcoQD_BowB00;=aTU5-WdLbv&-L*s?R{=3r$#1>My%sS z-6UQMRR;f-mLrZzn|%4)Y-bCrcMV^Rj_n<~6Q;M)l*OOp6v4bgi$o%0Tt-<2QmAtvwHfO+=*l4!u76Q2hhf;1b+m0=bNumV3j{k&L9p11sS{(;};HS0lRAF z3>^lJ-gcfV1Kr437>nyO442rKp5j9~i>4!di(x9I3y3TD?7!CU5A-TFXN_j|lJpnf zm#p%)1_z@Z`$Ac?WRrTcJW8vB(3F(;=ch~Y5;+hiutcU+5;V}{fP>{U6VvO(W9@Ck z;QcSrLCgN~N%+!49g9C8`WZx!=QsM=_ZH>ak1n*9+b2cxEw`e(T-94P>@;U*YT`I* zCCrSu|8e^h88ys-$moY)IJ{@Ic-meO`3{ZCctERmt+J?y#nKko%8RXT!k0|0bBSu! zP5^vY+Q#FmZ*r3uDNO_7BFDymb>TO5#{hxbanSB8C+%WHGD%JL^z?T>jID>#p{hs* zR0y?&Sk3Kf<8P;g8=dvf-cY(3_4oU zO7IU8c$-T+NN6vbQ1q#ivzu@k3H12azoP8A)S$f8JcF6SM`vgshI)HeAySW31@6{h zFA#nE&nhpo`-^9CkO7 z&Imj2CQ9p3ii-2aqGQ!rWzJZ)h%{5oH#rRUfE8r5!NtU8s=b^?1q?aO<{^BFX-`ci1IwcxS0-Rt&j>Q~cNr!;+i=YljvWE4* z#@+@I8pIg)cFWegjk*|&V~Ksdn&jJ`gycjv2(@fq`NXyS-LllSl+S4pG z#lv1c)RQkxJSZgy9M3lv^KWNgxo(gnKsmt!+QJ1-H-lwe@nG9l;_xsH+Cc&8Wu+CM zE%EwaPR^934eghd#sbo}^_SrrAo~Pdfq_s;4(f?w=ln7{!9W~AcGV9KiU96~jBdC1 zL~BR|h1(+RyVG7hzL)qQR9=!7{8qa6EQikoaey|aKV84w_|`a5k~CHQW#~czO&5+X zJR|w{(4E|>iG)g^hLekI9!b^O0z_u9g+F#Q)p+5Tgfj;xTdsrnh$gYMfqlBe@&Y^V zlbq#ATpGd*x<|84r1szg@A!cp$dF$&z{LLDu!|gbJe>?P4B7)8tKj}L(`i)5mTdWR zK~Um&p>6w)RFnfrMPW3v7PQPkZ`7Hw)lm4n;2cfQG>e*SOwJH+uzPNsR)V~j^ZEQW zIBSWM&U@&`k*JNpJrE64-Zp_rSPU`k2_6^;P70#y_E1l_=Nf7*oq{xMqI<95FCLKZ z%G=iWlHj(K7#4nv;w3hoJt;X?nS<+P&@bCba1)xEgg*8RcOR-IvP9gU^c;`IgWD3{k9pj#0BL;6d%W;A5 zg{JF8r^^Bj+^;n5q7 z4Eby^M~TpGk~F5L?%j4*99yb%X@BcJ)oe|TUqfs2t^P?j(qdP!9fI4}#dbGnwm-&x zbv^gnp7)c`8SEDjSx7JF7wrDxB+qDnVce?AT&1(;nu2eVCjv2xEeyK7f)5v6>M^4X z(QzVJSuP$vCFDSNr>@5B)0|@~@8NTchA2;TyV5RQn~0%^cQB~!HD{cYk`B zBdO`bVIZlSt?uRN#f7rPE^Kkt~pTjqWo&VfPW+b+0aI5oTC$a;IM~3wNP99k*U8>>1wK z#_~P#I5iWDAJ85!7(tVD8VP381P(2+RsPNLrCml-<1>V7U}N$$Y9A_@<)opXaUSwk zwOhdL9*Zr;6fih-#e-7yKE4*?>8bWR{k?vYO|MgzL)|ZMBGii^=O@v)`>#^ek}%ES z2(xO{rwQ?2NV$-uSjB?GSpYjZK@LxLqw-}ma?QT|zNZA$3BP$t5uRRS>JMF#+YF0= zd)sjS1vSaL)su00WpiN0-mTfT%Tq$~N8y%+F^Bu<1M+4uj5T<>ai*EcD5$zW{Q_5t zBqm1jO@-zWE&!0Dg$?5nS4R^=3_yaakb5;!kG*= zF$O)u$TO^>BVQ^c#@&Dlg``~-T3wh^Q>;8$A!WV4tIBQHGiN`g1<@UAt3G_}KUa*Iu4dpW37CXj(uhb$s1fxg7eINJs zj!B0!bN1F1RR{ZCW!LHI!Ht`fFDvO~I~UNauGGy+Ok9_duOufNwis_@kxDTsiR&Snm~bJDcQ4h-GXe zSVg8pr-N*riR~*{Er>AEwpfVI2Hn!HZ9>Mzo`gHScZ=D~wMQEOEWZ6JkT{H{6xBfe z;v0q@%ZYX<*eT|FW;Vy#8wcLgbC4G<7GBw@6JNV;$eJon$+?#zFP|vdy4|ANn1MT9eqPnB1ytd^a~h zQRWShoDly8*e}L6dgCe~q{wuFr^z zo*MmNWM3StaCcu7lu`?~%ak|)u<4WF7pLUhFJr+g_-OfXrdpSowz>Hm{6m| zcU=`80Uu{O?Hx>M{;q!SV?D@+mybofcB<;6=5G`?@1wJP-HP1lAey%MFxd= zNmS*dtAn;qlTQJ9xid}~&02CBSg;yd$7P-g^?8OeCg13YRWBJfuLsx5V`<phb$zYZHheLYGRsBZ{-3h|=qfh`suf&+xjZmu0 zHavx+X=&Dzw)tC%XR^`SU0YXp?E4kRpfm`A>&_UG9PCOpSZ>w&<_>%}440&b=j5-k zO&FVqL`KV4WRTE?jOi zq%ZwvoT9jUQy{1{yQSA(Opgyo3NY8hbfnOmx=F63`_r`9#hCk# zO2#ilS?z(0g*%hg?z`el>dxG-cEUl4u`2!b;s67;^!JV>sSemgBJp)~Ia5n6hg0v? znM6SJg_P=)lDk(|gsfArbB2-Iv1;qT#|B{x$^0mjLQ8fJ`<+d2n}QQ~36I}x1l?&u zb10JM&vr1cr~40YlHQUJuYoJ&S$`P4@zv-|hJ3WpE;$McuS|z3axdDGd~B)c7zhP` zJ03GVoXbjx9p=+pZC7p|u`g&$2t#}w(lV_MUa56qY_g4E@u=A{${4yy_PXwlcvqcc zN7n$Ou@eDrkPC#Ps5ItHEs;At_P??a6RI*%T4{j^m|;ev34bZ%Pr^#2I{TTa32H!* zd+$mSg|@{^3?Ca6oBg}8GS30Qhu1t1(bmrbu<)wIl%J=UtXC5sOBx#rt5udgdD;pv zZbLA^6jn9cH3_tG6R~19UfEtPCv=mMonGn>!i*nLINAtV@6SY~PS`L-YcqK)QE%AU zo9nj?;XnHwt;KRD4*NF#%&umd3~j(v6jnUs__86o!ylUYiY*_MCY3TTSDupHZ)PgL z;@O*s7v^ylxK<=LQgQn}=5f);iE&QS&@NH@Ym%7#BxZ zPv1O8VSmh%IW4o>wQ96M*KW>bD{(S211UQcxDr%DL}Hv&%0hI9zI-L;?u>{<-yo)C z;%fWL@GH`Hb*QuI^^rz)l9HhvQam&~c{Pf#d|hDocUwf+ zJhsM`mw;~RHQ2u4_eg73YieRaXvXi1|2L})NA>43u>0=UcoaHppiUHDrftlQd(#%Rp*7`2ySQy>q|JBc2DSj9~w0hNE zUS2gXJ0MVfy+0lv{pvx>S)1|5{qT6nm*f% z2dw8!X_|Fo`qBlgf@ZZRPDfTG7qeup4&%M(IYli!x@;56g-yy5xbgWMIvsId(?sUnCWlS*@YwhLP5PR2PrQ8l{v5Ux_C4aeJ(t$ zvzDN1O+HDpiHuGc1sz)D`Zd5CVQ0LQNxaLQb3f@BM^(K@KAjeWZE#;(Ozy?P6Ry(G zHkL=jL=q`?6yO!vyT%k|?B5P@8oep=!-Zp&P*PodcIEw^rnMt{cb1*)^nm+yOWcgr zz0ST~HqfA3uMN&fReSp{%!2kx$mD6_#Li_f{{rXy(&C5j-PrKST{GIgsVC!VRTYPz zmBb!3l9|(@cTq_*{&8#VNd&D_Zw_M3;V|Kz_w&|x#e$E_yW=iug^TgpwejYLOc43R zSuVlU!_Ouazo|)9cv`$&HuW4q;^UxtKH!*`02S^~(M;M69Qs8}9O-~Mx>%&S>}O1Q zZ{{sZ*5{|)`Fp;T;Y@Gmf+A>5xNyMQe*GO;)pgCMQ=7)-Ph&`Krh`1{ZuRI(u1opH zDDJT`YnuF{p+s|o{Ed!~#2_8%YMNHvA1RNk?o_QQQZ&%s;|KUcs_RI-{l-66X{mAb zJYE|J6DI1Ph$fHe6aR_tvHd5$$HC10fB7Ca2m62Id(2FntW5s}_+S_%tZZG)orxGF zY>i#b#mr3|%*osE2u}f%##e&)UVGIgk9fNxgkvJxF*C z0>b6c($vMn!;;;_(KwVx7nZdLqAl2%1IR4_c5Dsa4Ej}{NoZ{f_|ek*UpoSjJZ{}& zZX3iB%(4A390(uqvk@%P(9!rHx(T#11fM>_T!JcC8CMXIeX8`m0A< zZv{;Zs=b=~Q2 zsC)5K(?AO*)B#dC5Qw0rz^^>~5{)bq zhE+HC*S@o=@WIXz?!GUKz{9k3z8D4;mVe8`g19?_k4t}xdj-vX_g4us0dey3@-h-q z{?`_3{jK{#s69J_dLM-Pgcnrby|{vM1JmM12fhc_4b1t@_v%b*4+LVe)J&*-@yGoJ zh=76srx?Lv_2HSphvj{tenCTMe|h=g^sxQ~HR}5=;wK9DdfC}Alu*2H2obsD|H=Ph zHsky7;M zM@@8ZZv7dYx()yOG39Up+VJ@n_e80ty|e;O&Qr7jdNZs*IGrt42hk4F$NyfXfn@d3 z0b`D6{@Sqv>y!%S7fPuxN(-INwSGfgeeX7ZHiD8uKL`2#sshr=k$4bnDP3-;^YP}< z%OQIUhkk70{^?PHF#&G=>^8yM0v}9FmDkT=oZUf%#E7flT^ZFz<0m7vPgYj_& z=KFI3TNlJr_?1J2)JFl`5T5=e@dNalgXCh#3d=jU=<*Bv1M=h(_s6B^huN_gxK3N> zDi`Jzao_XziAqXevRL;=4%U&M z78pt7=N$6d+t=I)KPQs)4~(C9k~A7aRpKIoSj(0PdC#10{+5M#rWns8r_L7?*63X0 z8f=-vLr=An&gs=hzcY5qlIODSYU|2zr7G0|xrp@@f>~k%b3|6|fg-uA2u&b7QPX+A z;jKYWj+?BfOSl;Z%_+VsiJvHl-n6OZs(>-)e*@IG6s&EBoOFG3L0O?1 zyeNwvnz<|5w=Sy6aLF07GM&6y4*v&OXZ_~PpF-j56PJ=V=XSS;j;J<#sYz|DP8~7FLRy7P#0JyN^C6Cs*kwtSLKl8P|DA?B z(Gy=!nD>o2*x4FP{x@h`-2xFQ_{XYFk09w0Z>7=}5n8m}B^6p5dq1>yS58IR5UQJ= zxSyLvBi zQ8~ca^nfB2CKb0>xMI9m&vYiW@H~q5lQ0uQne0;5thiL>epM#j`!e^#bhFDVB|<;} z$G{B;DT_7RTeXZUGL!*Xu*4?roVsp?7U}AlrKOu9`AqeeBRXLYGj;qmOhVz9do%#M zT!?U`KYu`=BwKBUv{Xja)W6f^p*@E@$8jz1&o2i&wmDq4W%(eHsyRXNQDK{}`K|%l zPnDiruD52|CB-vVNy^`=1vYxl8n0Z4sxqjmz!I9g5@%ziJ1_kOXBa@nikPEBBG8C8aZ0aU zhv|Gw4QW`+x@w|c-6KQ1PY+hG-)}OHu5FVYtSP!z)g6J-aswiw>@iRgUZr-|gETq_ zXo|n|!xiRvvLowQ3c6UrywbsORMQn~-|kirlX*hO>cH3L zjm3k~xk|pD6KB#2*cQu#)qGc3sC6cAT;sx2%IfI_bK`UinXXKo@QQczA+G$<*^U z8C5D#^W-=pMS(^LUlv(|Y=wnAslkvA=qrUG3*@ceX)4Hgy39&vp%aZsa$4s!KIi~g z7pBa+GU((I7j#Q=Gk8g3CM3xS6SL!lx|TXiN`GzUP8W4H8RGU06m0Vh8Vi~ZMscD+ zk8(oK!W2jE7urSv{_A#5|8_?gJz&MRiOxH)!LQSd#A&~DZ{n*NI~6D4R<@TgFWF&P znzy&7PLL4k#KhJk;lSig06pjoeIx9?i_U{K{(muc4nd*-ZI&(DcGWA}w(-igZQHhO z+qP}nw(Wi$F%vx#@h^IryUa~Sz*C*IAZ8G5)%Apw_uH8dMYE699tv!8683MrV7-~_XhC)udUyXL<=^=N$MIq@nzYo1;} zxYY4UkB8bS5oKjfr&f3=dVPM((|YaSJ(XA*A7OmXG;*$fP2fMidTNc#bn>mphY@Om z_KGu+E~-NmAMQDfQnb7Z?iOL5&T$M>JLp1u*Vhi1{-kvZ6f4AqZ?fer_7g`NHub6K zU8q0ER3otzycXtb2!2wTmn68?k@ibGwbV224J;6=jx)2AjYP8Gyvy}h^tMj-! z?Dlc+J=cicc0s@L&q5w;YH>(^5b|9(XwYdrpP+B#F?XdX&HStnb{;YW@2x;mue&}e zHxJx>SQG@RIWSGqZ)`n}4mGI+5{mA~bfk1rX9|daME*U%<@vbhXnd*7z$JU?nE5%B z9JfM176&TmGGGPdgc2$s*H~6rncYWeYS1udv;{riC~h}2>{NOjb{VU|@Aoe^5UD|& z$WqTyVf7M9eboku*}RB9SnxVDfm8jW+atsB)iMRI9~DXk>3YF8Ek*by5iU6*%|Dpr zO?@;pqs(b9bFDfh=7cH^0uf1K%Lj$3jOqPZ{U&<&_dy=wqoLiftU2<6OcE;vaWFEX z|6YkxL}2uG*6yPI%3k;1^sacNYrRMR`daF1!ir_>`dIx4O{(5Nf#gPc2R#S#B~oa} zx(gE&Q~$N<-HWnI2z`e-^#S%?I0sncK#M)T`4i^YkvWNK2i9DPM_v||s5fy=D(I-e z(zERCXy(*SDPqWp?eELw^*FeDkx=8Ew@dEj)-uG@ zc%1A(_tIeT0m|<%kE2Vgdo!V>IE#vfpBJbUjuda*o=VRFl|9PIUa~DxFJGmzz7TiR zm&Np5uAfjU@7_E zdPVK6G%4+^sh`grv1W;tBpps))K&%k;l|?WL5{A%vD3EO%!xV+-FNiuhV_$RTGweK z#ShvHAMUNhqQyV3XB6tWN{iNc0x8;*TLTxN3g?de8Th;wTN%3kZ3^wlzbzEVC)~Wsi96ECW zZUGZZn90;DX3G@5`x9Qu59;tk!k(xsXesN3;&YUt3j`VhWH&1D%A2>)5%=fp8&#!% z1$QAXRkrk>?3GPb_d(Y>!bd#mU!`U0Fh?dyQ){l+VpQ%qM6W!2?<}`3-&aCl%0kl% zzB9?w>HDJJWH{jHz!_`V7Mf8fs`YB$ zq$QdYt9W){w*9%Iy|l7?>AWI}T?if}JHG+vq&U%nBT_!bTkr(eRZ8FfgGEw@tHa`U zf!|4~dz%uij%vm%8!gK8*ac!4qcb2%9LJ?jFC2K5Q+2*~n%aUq|HfG0w1m<)%K}L4 zHg52P7O&6sB8U5iMG~@zVPx9YA$Y;DSgs$I(|VmnQ5wq=fUVwcPSl;{z0&>bKyoZP zt^M+zX5qQp^E!%SR30HKCKwoFvN>|*zsTxI=@M3Q?i^Zf8KcTZH?h;^F|){?OG=wX z(pI4UuEFk1@7U`8A-_Y1AzifF)nTzcy5yk^whJ_Aum$osnPbvi7UVL+kd*1C1mrqa z)U1h@6%r2_#Mfy<=h`rKCsvi^!nc8Q8lSwy7Fcj97WX{E_Lx(xq+$IPlFb9z0LkH z=-~8w!LY%&y7^ZRlX&1rMPY3fG2MN!Vkl!aB;=~340=cWN&I}nX~V5aKy+WwuM>r1 zw%fDze7z&9aWP&Eu{2q2E&-2q?b(t4bHHQtyfwb?TONn!G?mLJ;S~LgVZJftXmH6!`xnRTNI?jAD=y@R*F%nI?IVhAx*&((qwCG&its#EiI z#8(-TRZZkB%;R_oH|z3UD(269{rU?@tAT#FR;5UFZ7i%im2_3=5oOJa+$Z=eX}8&l z?Q8DyJ9umhXXr!6g5R#J<@L8Ie|2rgzO;6&Frv6uj`vP!Uv5_HT8+fJKKJC~MI6nb zc%yzet!?bt;E#maK?pXfX$YPb^jIyz4jj>I z{I&=qMKA}2QW?dl&5&#qfw_t%Sq*SgA3EB{!-Ej_;8#rFqim8%L0%&QX0+t+dxdK^ z$&hmB2K&C7PQi7~ zErDTTzS924bJ4=K^>7m?W( zoH@@r|3FEX10P=A)v`b9RKg7yq#pr~NI{7S8ST}~G?Z48Y1#)b7qiltjOivqr! zuZC^SrKEyst`9B%(dkkxqRt-%L=wSLB`ZGX24Zx z{iqI;bl`b3aUzwiRC<3Mq&2!PAzWWFU+wJ+*amMhhsbTr5-%6haAL?)4U$FZu+_A1 zaSMy0NLoIS#Nu@P5pft>`3ks!rx)Bs|}1zcTL$&lf}zP0!(?-$W){Gx#dLWaiF8#yH$Xx z6&ZFfo9p4U(pvOu%=O+ff5dGfX~r1WL+3r4Ak+sSs>p_^;7fAQvR(sVzihsMu$540}nav9kLn z1l1V)(~7f)dPUJ@0<0=Aij4xddu{4o3bPdoa8z}~i3eru;KW$MrCjeKlo8tNhMtY5T{bLT11;49w@1+%@oNX`_u+#PvCzc5d1 z@BXPonyDcoDYa)8f+3^xo|W@Bzcy~g@q&Oh93tV;S9!c3=5?>LiGTvS=n+KM&Uj#B zwPHBdPHR*r!pq@23jW4(k@T`oN1*2_S%{X_KJRc)Kj*|m&INMvk?L9SEO0GnXbu3C zK`$?(&n$pUdne!V7wVVC5Sp(+8*HS}Q8Cf(AB+6u`6KjZKCDY(c*)dU_s zK=StkH7W|a-2v}fB3R<%WeH&Oph2Zb&g_?aj(oOhMMh>)DZH4dvTG3&{U?0Kd0lm+ z4DQz&wjO7W9eq`fz~)RGHHxX4yLA3&9#NdA2HW6$TOEgRfVkzz5lFtTTpLEt0V!PXswC&Oun_ zAe|Ku$|-mIdMpftO;r}}bI=95Q^vhjx3dv()XAIN4;%-uKZ3X)Orq%-p;*YkSeR36TW2}iV@##|K1=0$135>_goK4wZKf#u1cUSxWoR)goWm+BPff%G5p0 z6i!bIvPnV{Z|ed^I|X@N`NA?f;VCt5>7c|{L}KFt29Wosi{>v=pa|QiYptxPDR~elqh_nEPblpQV7=V9vHFgUG(l=-JlIXp|*t zgOZ%`3tdyuYy#=L9u;M!xdxgyhcGHTPvY<3PLKWd3fHy4p_@29S@HpNogUevS2=Cp z3x8oAd?HGX{)&mNdNAuyU~0s3IFnO3v==Z-qA?B2J{QCqr@z>_Imr)@%6#!ol)36f zc~Pn*Ar!Twud|(^y5Gr%k!ev_6hscT^V-&}lU@CSy(OP~;EQY!_X!VvVx9Ty#3(nOxv)!cEsS06@v^pI0vEJ^&w0Jdjs! z&T_SLe4yE-rm{VBv@6iLK@lb`?y9Z5vso@T=HobT%p)rW*96 zIi~NI3SN%Q{lLCBW?jwK@nU)RNR;a^>#{Xu)DQxmMwBtg99Iua|G)-pL#`E|2K{x8 zfH#YYWj}dNSxb}c70ug5BFVicSDYePcjb_LcT19BV{!Eo4e!b@3$o5_5ZD2i4~`Eb)k8ZYAFGyyl^ zG{a35sM$XFU{i4ia^5SgFh0an>xgrLl$Wz$wiL)UX_Y->JEu8T&)+yi(8l->Eq~8g zJV=@YyB9{(B5y}5L>tTrJ>|j8aub>U`&aN)3b(u#6h`hfpsVGGBzSk|<459fT$Q11-huTQ2v#G$ zd6iGvvEpJR3pXk5)4`{pX)j5~{=xXzIx7i*s_yv=@|s=c8Dy6pcLnQT%6nBnDL|np zx4J!9raw+zh{U>a^4@mo8k;WI;a>(8-nyLj=TdmUTkYwONv&N$>c~Yrh0CRt5D9!M z(9ZU|!~N%M0YPsw)~j8v#*+XC>$=24ALANjZsnFZaNZ`eY_4H!!v`wkKN5w-1S8@Q zG>fTQUI*VKL1q1MO7eg4wh|;yn|vBlhPC>bdeMa;_(8h{~hz_h>dE#sMLf^6JLuQc2Be3{+-o-Maty)DZWOW|tQfs)u zOsmwox0i-lOKhN=4a2X+Oi$?;1KcX{qDN*H?;{t10;`g)R!||f$a!fh~da^D&@pOywv|VfkM76&ByNnFp0y+08OMWM#SEgXv-OekznLeH1 z`X)iBmoB$-u}H7(TN7`MGt`)T-bQp7OT1R#kZ^3n9s9I)@%SgzUPQEkyB!hM-pw$Y zCi(920V%W$Y0odsAHiZQ!-HLbI6k?NF@chdo^b1^)s2Q1^qBSI@VPj4&!7cY*C4|z z$cAm4Y&PiGQ@QEOS2jbCCVSk0MeU`T^2$m_q;35gaM?Sq&^;=;9SWhze5i%q2bqQ& zB#Nkk?1FB89Ok$5pg3;d9D*Ie?_qioYr&Za{@4s`)MUI?DPwCtHX^0D1#|DFq`Aol5n6e4g?I3PupKFhBAJkiNpZ z@;&omm*iM-@4=$@0B2AFsJY5w7#n|4p3ja*Ao#L&(ykk-xNn1IhF}(Y-M&Ji1xlS* z48ngQ{e=9O0G#tzP(66UZ3wQcwv_|RL9LX&Y75P7_B@G_cf%M>_+_*|y)9-I?fV%I z4@ZzvP=0);)ruetqBd=Q1wmqK9u13~T->QVCD-e7_S6^KWdI6+fF=aN63YvSy(G^& z;9jId!$N1|GU5kNxc3BK{-yt#h~#T_70Fc@$Bc#Yp{lS|E=m7#?k`b50na_|a{#22 z)y6Kqac%wbj&+%FE28NaM6V(E#|s*lJSO{kt8(pYzBr?lFjLdLN;ZBkg%N^xRYIK7 zbJy!WqL=3ON$rdAiY~<3H@Lkfdw;&$dGLSFk3$%YWy)`6mIkDLOtjfZH5`zLXm7~*Sb~aVl11xK2KU3S* z8o3JtG^7gM{c&Z_6DDuSB{Q&t72Q&Iu+z@~beo1CgHbzC5G4cMW_ohR1f|26RVYgp zE}VL(5a#XsNR;>F?0G(#1iRS8P|jUDW|wt)>pUdN%s3db^Q_VCW3vP&{_3z8FSe`p zdJ}+cnul^Fo1MN~gs7MA>%GG&XjbsB4MGx3UB2Jq9R1Eikv8f6$X$nx5vzC*PxG@t zaAT{oM#%J$e}jG5`0v^_*zC>A-vAn=XhUUXtmOqypAd!x-vnWQWSbW%J zDt4Uo+x&viVX})j3BudyqIC?9ulhxgFfozd$)F>=iWG84-_1^Gj+rOwIF3pr*SWnK z#T_26l{K!1mPDp8LfFl=UX({0jKJw-q9bl9ja#u*iJa{ui5RO1@2CyV`coEXQQIFX zNY?9&)m+oqBQ)i46Bj7RMwTibjqjhCY|S% z=()~QYYm_GFxc38hF$PF=lAH;Io*hm%Nt*4s>FA1_96qH%NeTBSF?@_Tuc@#1g|*@J3{n+f&JDH;&F z9ygHWwVaC4i(%k7D@VLMU9xCZ<-e%{P^j2Vny3EZKI5I5p;Qsnd|g6v_uuhigZRR$o_k#klZCUjm%uoCC(LniUywHAljEk3{$;fjL9Ywt zzobilM_s3|oaS;8K_+rYriX;W#C0pHR6VqucHTq*$(+}5lceB&e|d@%wQQhsBgkX! zN5kdxHP3sq5A`5Pq|A0pyFqh-JWi6-2D;_%XiW%c$fa^-IiF|VeD_9C2=9u+%WALSHv@U1@*ualku` zFUZ(|L@Et8JbiDXUjs|!;;<&G@bY0BC;;y92efjaV&W0gVE}ekEMY~nTZw*H@^4Z- z(!a$4N>^r(=pW>N8|3l3#T}um-)UK`Y>%j5Javaw2KxnHkiMe+PYqMn|I{#L;$Zln zhAAt<|L|n~KY|Pc)Bm0z;{vLLY_-cq!oXP+FG1cN4OZOARXl=0uZJBK4@{hdgh-4^ zT#P6Wf>@O9Crfo;Fu5l}3l_`BUe2>=lPlpKg(J`3n3E>?&L ze_%)`d=QwJ2Qm9`nY!h%H z1A1mfbSS9*$VLF$h`e@r;VtE?0igSfE|;L5xK?tSI^+Yz841Hg}d+Ya`U0Du5F3H8JfktQa~mV;SBs{52Hl9RYKqK)q9Y;GRy7DHU`mrm+trV+UTCq z89)I4W=~>B^HbxHKf(9m_A%;V>_b2N!anZ)`DJtZop|`y()4{T#N9i5+@yS({PI=H z7Y~vh({%yT0j>kN!p?_;;s0)}g#Cioj^48g132>4e6EuN0$-BDy>2PA{A%k#|h#WVspE*1pyX7B4Nj#Z$sU)M^<=LmQPg&;W`#BWDcCJrSMNe_3YyC-dQ@F^9Y^m$}b5Skdjy z3$y&oWXTK1WiP|x+q(smhGF8(^yH>p4L2(^Ok-2tf#h<5Zi0I({Snb7ty|Ye$DcFP z6!uO~rb*TYr_1q8>*BR;bzEKr_$+Y@e&4Ag0Vi)<7!X_&JF@mZR)4^qL@348|5WJt zNhI|yblUY%Uf_2Z-Kjvr{^>q`G*~~EZXqf0>jDP|=jnpCz*FLSTf4Wk%J{s_doUqs z5-ElY^cT5^w;7q^LO6;}VO$$tplE_m@tmZ)>!AM5u`s%$9-pKHS(hpK-R)SZ>cS+N zE_`a@0@97H_my-I%_gffp?sf1cXQm3BRUWro|blkRQTdi_e4oyqcdZsGq9Zrb*z$m zxbiWOsPV*sIxJ;1N+omVOax>HH>hifL?Nt=C~4FcQ+_@ko<1x|U!|lUJy)*1IQ*PT zd3pEbfB<=*5-6yqq2AsHVZY!*hYsuZTAe}gz!<4%pL_Za=!gAm zKzItLq7gqyVa^vRb<~05M+58*@ODN=vIpnL`@dnFCAi=Y@{RADhNWmMd&lxBF-?_r zn#UN<+MCO-ei23V^NIttJ6kFPZ{q}mE>gl42Mh0?rFH@)Q`C5|haEg_&H^Oc8*K(15C zOth07YUM{gX1>23MVr;rE z&C?zQ&NNZ)`^UCd+`)ee&K$}PcEhvN*xd-`Njq13mcz+u9Lp9rzsc)|O?Yz_J>6H< zLO(L>tk}#m5e}m%VG(K^8R9M0s+2V#KTcfm$cW2RbcYsS=;)4@ZmUmzoL_~1%b68) zZI%xt&jQxJwc>HQwx) z@@z8i`iVpOwqgeI?hrTC<^{2L#J^6@Mv?b5VeMvg&dL2d7(fgi4l z7ZKZAp@i_g!M;24-h&do1%L zjz@x4qPhwuMqtb}UaQH4`4;y87oxEX{jUC)CR-_-qZ7KHX9xP*{%P!5>CvSMy?t>x zL>p<9iULmNG4mKYT0j!x4kXugt*i)J`$T~Y#nML3-!J1g_}td8f4FBS`ofCq{WI!p zvyB=Xdqei5UGO(K>d)q>{Mzx4YkQ2jg6>QizFIJRVJx(AVwsEW-1!tc82al3Ik)c` zLd+da;7=YqEqmCW=O$Q|fycc|%J1T|CEVd*w_J4ZIStAeC)ZPR9iqm5w- zQVy&M4!Lhl!D(`jV0oZwe-eAp_({?t&lw9!2)J`fL#fZjgbMm_R7Gi@O6en1k&P<| zkVC@>U2mBR$RQ9r3}9+UM94C&yp(6~;a8DW)>1fS4Q~b_r?E*Z)t`%JVgFIY3IeP1S_NULw8GT#)I2G3i5O*BRiDREzB~gPe#zSrTZ7(xs=s^a0?0x zm^OegytACRqdncp#*|}MPlMYU6~4>^$1`G$=t`s=_=p^N7$YJ(CWs%B8}=#9jVW|o z)Q=w@I4h*9_fTosYF_|r)gFPxxoO2L`Fw;7wzx6Osi)_Ucv=aE1W|^v9w)@0tepN8 z{Wnthx*PWLM3TnjLjkjD&UxLX87fR98H<~DvUa?6UKS^aemh*dbur`r zh^c0KEKj^?BGz8SlZwiA*moCIG6SJ&D3_wdBO%oj^iuc#)y2>AAP$RM>HnT|@Z-Hw zP=U@C_<5KSntkf@?~f5raKg-(Rz~EVS`j#zY$>vYd)QQ>KoLXhNo z+Y&S1r9M=q&|v=CauNqWe~KRxmD0oGV(ays`eM$0kgeZn5{uG$64tfQL!CTJ5rYc| zUikopgt790YlMPoRlNh;jU+MUc*6aL+bUu8b>Y~0jXkNDnn8r`@>D1)3dJmrUu_+F zcT6Hz!}ofCeyx6W?(9Y%BZ)2Ei_l1ehFQ+5@fyGzJxTHB24qpoYlxeMr;=Poa7%rLXhio_V8N3z-c$V z(Xj+ru1lbv666xi6kDxflJD~NgNup%j6jAIaz~7PJdZdomk85~+R)ID>C?{4+YD&2j|D28eYLucxoTy}>%BQqW>9gH$iZ`nUeRgMegB)qb{&P;2j|wK7zYA-uKA|I2wno~~ zlNgi!ju`U|Icv#BWK4#c{qGgX4ic``n_zNg!iVr9#JFO@7AkL`-9gPEYEZc@VaH!& zlvWzktn0{VwW(8V^O&+#ZG=4rcaMyPs4;z6A{pmHH#!*Pej)d*auOI7fca4Sn(W?F zZ{~1PnZ}uGYZy8o4BQD8j!6*`uBx9L?n&n!_F_`bYd<@Tx+T!is1`@n=oH5HH)9W8 zQo5ww#nJ9Qgd@nwz2ZzJWD=ggyMnSqAHjKVy!%nZ0L7Mvw~%H7pLm!%G51-0Z7U@w zx3vDLPCS2zPw6Am8fe$iXI12bKtV_gFN$1+YvBzNaXtY3csYwr0@NhDuzXGRb1O7K z3!kP`4p9>+Q<&Cs%8?@4bVo=~wrMsVU@4(XdH-70)ZVfXs`mwH02itbc3MVTg zXsn+%m0a@ejTwiqv+N2^q|DuzagTF}4eO@_CYIq&Hb2);%$#uL?yNEt6*)g-+}{qJ zrke@#=Kb$_?OB5!^TXsrXby>=H8N{^u4O+xj+En6G>xih#^zy6Qg`IRO%+>I zoU_>-ov)Uu%-d9Pg^3XpPO&F6Pw(3TvU>$@)7id&cC|glcCIIl8sYi3 zWKvH|G?E#fIiVIw|lLWK!cyi=^V6wvs`EFiG=OOBZeeWLrUH!FP}!ir(2fZ zjG>WhP`BIozg(e@GOtJXs&fd*Z5XY?`2NwAlVQyGfiM9I_XPRPkr4G<)jo{mX z(dlHSm#QJR>kRo3kA^zN=juYqwmE*6d`G4tbX?$rW5zhZs!NtNZONr5Q9#2r6nb9~q8-LiKA|TlHQ(Po;qO?C=^w2Ms(= zyR<$5R@uc!Xwo!6H5!=|fCe)Hn7rlim_^b`Mw3FuQZ@hOZnzo^etk$-f8i&GJeViUEc3x1iYOuwZM$-6*i!j+(V`%xhT4dnJ z4jGn-uf25p71z9N7rI5D{Xv+er2tE(YUwJl?Wsnz3%)=pa!>6=+u=+@Tk*y5k@$UE zNgYPCe3D=Wr{Dy-{7BsPEHqaqeVza-kZ;;5*jVgDS_%NmWsRKS z+mM=Hl=e<nUQ= zdAYtYr;0sr^b)Dx9EMfdZ2O~>wsXld>i=+{$2UjG6~uT`G>$KCLA`g8iTU3DdhATK z%0us~YY|-Y@-d^oy__RE`vd?FuESXyFu-$PUbBqyD7e!(r(JG7FX$Enej^n_ z=3&$|gD%YUVJMV;Bh=E=bgT9iS`s{^J^8U@s>RtXkxbge`f6)!%iK8~WJ)p`v<077 zJF?%{#;#uMjz>)_>L)8`x(2@P(!-P5$Tw33=$X!Jp+MVS7)bRiTR1Bbe)WRimAe$z zM6DHcEl%}{C%TZlMt+Iqu2*g`URl+AZ}`r*#wmtq@9CA zz`dITze(^1u1h`y2YvJYVlS4SGTdMTu}gU1-S-9K&(fBHL3-eE6R5xOLy7|IlkDm2|2 zzjU)Ub0Y2`#lRyi?Z&T^H?5Yp><(MUlPHe%T&lz(2CeJe#R=$qc1qzI0vf#e(r2Tr0P$%YI%|j^5+?uDyRNSmi zu_Rm-j8suhZCTZhr+o4m(yOcLmgFi^6e~7gyXF~1m&>A?U7c5kbNK>)I+j)}jk8Vx zsyzcmp;D5&bhcho{Hq-WH_MgY`cB(=#i{AThw%@GMk74U6IOG&yN9)(Dk7CxP3}@( z9e2aOw=FOPQw15d+MiYmA4jSEd!S)goF(SsooAp-K#P^&mZ5S`v<00+-Rve04 zLR#zzJ(jh}8-a)9Kbi*0qt#~7H!{R@r5QgLA_C?Wmy3gl$Gk*`Q!@$Qndhv}I?2;E zqNLi^UAf^%sWc_=cThDlJvAvD7k`QgiS3I8H3+R%yJuOhT(Tx`rX56q`(j3;w3>NB zh8Rq5K|{-nOd-z-r#+hLu3P)gTbQ}{cOlPTCFWn6EXf16ID$n7%ivCmunZ%dqeZ#VRC6MSsCF@I_nYw!y9|99drz{N}k;mCrF9=B51#XsfmSAhU$QbTMD!0|oiA zmxdOh?5#m1hPk}l_w+Jbi&i#M92Gf7kFyUKr>E+g`qqlmC?v!)?|3e{7B}B*Xc~-= z95$qPu&DCf3CDtZqBLiqxVTkIFVzPSO`~+3q)#fvcR&mJ%p_~tU)s43bMl+%l|#`4 z7T8eg?O;z~k7-Q+H+kICK3+#EZ`d;uyp(#@i%(xhZ0VIwoL}YMi}z1xDB{$ip5-=@ z>EPG0LLC!%d9|*!P)qUimne50UoDh_mzIKx__Z z<>^n`;~z`JD=cA{;Xes^ICR?6{~FPIJ}i~rkUj?_UAE+LmR9f=$7>FT_;t;7_-^1X zwzAeKbUxyWj7PnWsP=p^B*1zl%{>=7Eb#xSD1W5S5AhMFWDpyey-zgN0EN!%k6*!=V zgi}BPu)UeRWn40hS-9DFt$4Wb*%&-YO~j=t-t1I}-4y0@RIOf~7}1)5Jl*)E0*Np%5|5501Av1W5^%|&@1KNW!+utU4MPVTn=APQBB zR<>Ne)WpVP+Mj7d?v(rt58!s6mkKvOMBn%`D<32VJ`x8gx|5K~6J4aNypFFqQI%Y; z32%Bo57GcQde6r9RDa+o)E|4hw94j-{4g`UXB#~eqM5jaNMDr9uoXC4k1eHlOmlCk z`q6S#Zy{<$9gkzP1?);E?f}Qf2ffh(DgZ+xEJaUnz`k_G9K6x*=Z?dDVUi}pEQ!(l+Z@1~~1lGs}j1EV`>*5a%>F7k~>u8x| z?LCb$OHA4ktB1L&*FQ1Tae(s?r}MeGmrl#FwQi*ZU=w$^Zd(gsbJjlIdz!<|O&GszeIMr4{J#i_} zEnhUCZk6(9U{Xuo6?(Ci{CBv6RoeR-jZsL8X(*_PJHClH!T)G^{rel+HmRGY!d+b% zEoTj}x4Zi_E~&7V%h0BV)S~Ut#(WU>N!syvb$eyD23lgh0n2QEvNWe0Rc=2JtTM9m z(WA{<7aQCEu{Hg2oPU#4ksd7xb@kLptb;K7{-RFF#hF5KjU;ig&AilmSY^AIBYdk= z>fl>}F=R-Un$-J{`Bg@CLi3mgAF7OG(M2U4NIG|Af1Lg9XwD5a$B#~GCQPe3a$g9o zkOou6fTRs}m24z!A=g`G`;X3>a!J7UwkdAGJ>I&$$=ZYKYy8rJdt}bV+tCtm%1|jH z%*~4H>j4U}LfSQyNmF?J<(pW&H$UWvNa6X~=~kqJ1o|?0i+QW|iF*|S!)prkNLNcw znwHu+qbiL1Cx*V&vjObs7m!;=40UES!Wq>@~ceKAgtIUk3;qGggBqw07aSxE-F%+Qpp!fg2|mB zM)i{^^70E~0WvMBaoJ9sk8Q2HYU*MXidguR?E3*H`T#<$Se{w|@4tkFNyZV1~ zMOJoZmjAVxLg}BGLc6s@@;ojH@EngAM7V>iSl2;*WWewb>eEm7g;nME6CiJrMhp_{H1F33aE2!baKi^c zNd$?Ign|SN3JxOvIff7u2SO3ZLGbiT0pQ1l111VzL*29Cg$OG|hVq{1O#z_gvH_42 z6wJDHW#^wkjq+Rb*8`wIn1H;B;GKs90mbOMCeTZoJuMr1i zAGk+k!|#I*Z~!_BjMobyTf@7n)b?W?L;dPx03>wr%SQ%zAGHE-<#+c-&;!^22wwBo z7kBalpTdg*$eRVjC^H9g+Ul$0o5}FSc?0m(h6Nx8_;T&&E$}4?=<@*&yt>+}u^~u< z2RVVT^W*Oaz%`ZzOk#_G2MAdIbpR2Fw{zkiLk95=u$qVKh5kQc3MXG*=g=l0{w+Ci zKw^-ON}+yT^SRV2kVaL%&W-?ma^m^g50pO;b+{iZ-XOlQ%b;Md0q@`HT>N@3aJ?FJ zuXf1H0Rme*eHIbE?s?L}zMDAxyT0H*?L z$w9zWL5m6c@R0v=XT;6<>X|98x98!*5@*Er{-M`k{^eGHGy!k@EvD6%X8?fk2Y5%w z7mL~n2m10e!`uk$_GQ8#AO{F6ocjad&K_{K%IyyzAVQA<`e97+A!fn_4+2zO zxtl14|5^KOix}9yqsLXSZr+x?q12oB0N1JkoWxMz>aDNfoBQx0mtCd!A^!+f( zfJQKaY+ZNlL~6d_Apb2L!i^lROM)y`mX!QK)Fy2?(JdUvdv1kK$m+N5Ko39u zsGtyas6-($T-!oP+J(kMVgz}dH=G!dqK-akS$QOy;XGiI6FRv<#c zy2N`<8h%Eiwz?LAU*2u?p}Eq%LTvYzad>R|Tg@c=HSkRE<55S?9M=)-ITdGF8AR!C zvYbQQB$tG$THrc7wZk96>s z-2MH%<~!wKPP2@PjZkS(0U{NsgMw|9X_MB6C9k>HwYZJWaxq3Zi)s`+x9dO7y27-L zel&*BYK&!qN1wSTyI5X;7^dk;`bv($re&{`73jKgWf`9t_urtoz_C8E^0BX1?MSNp zi7mNtzfQK~*H!7T1iSF`Chplf$u;FBcn#Bz+q-D=W(cs2DADW&lWT*@!vvBGh+SX$ zREU1yp$E;1a_)LfTYCiETo_ucuRq*O!nw?<7U?5y)#Qf>CU&<;=%xfQO)`V=q}ADn={FafxU zf|Tb38ob(9S7=Bn!DjHaB$~`iuED%%QDz=v)ynfYe=⪻s7R{AxUe5Ebb;7iAIbT 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
07HD;;8AGeGOTrC2_>vkj7>N2`cNBiQG} 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 z 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

hQTtl#NJaR``o-@?& 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, a Pydantic validator -(\texttt{ergon validate-rollout-card }), and publishes -one example card per demonstration of \S\ref{sec:validation} -as a HuggingFace dataset with the camera-ready codebase. - -\subsection{Reporting what was dropped} -\label{sec:system:drops} - -Publishing the rollout is half of the reconciliation story; -the other half is publishing what was discarded in going from -rollout to reported number. A \emph{drops manifest} is the -typed-vocabulary record accompanying any reduction of a -rollout to an aggregate: a list of entries naming the erasure -type, the rollout element it points at, and the reported -quantity the removal affects. We draw erasure types from a -small vocabulary covering the erasures Sec.~\ref{sec:problem} -documented in practice: failure exclusion, cache-accounting -convention, intermediate-reward erasure, concurrency collapse, -containment flattening, reasoning truncation, and attribution -loss. -Figure~\ref{fig:dashboard}'s reducer-views panel displays the -drops manifests for three projections over the sample shown: -$\pi_{\text{step}}$ records three erasure classes (concurrency -collapse, containment flattening, intermediate-reward erasure), -$\pi_{\text{per-agent}}$ records two, and -$\rho_{\text{leaderboard}}$ records five. -This addresses -Sec.~\ref{sec:problem:variance}'s diagnoses directly: 0 of 50 -audited repositories surface failures alongside metrics -because they do not record that failures were excluded; the 37 -cross-harness variance pairs disagree untraceably because -neither harness records which erasures its own convention -applied. Making reductions legible requires naming the -erasures. Drops manifests are additive to any reducer: a team -re-grading SWE-bench trajectories under their own convention -can ship a drops manifest without adopting Ergon, and a -reviewer auditing two harnesses' disagreement on the same -rollout can compare their drops manifests directly. Ergon's -projection operators (Appendix~\ref{app:projections}) are one -instantiation, shipping a drops manifest per target format; the -vocabulary itself is the proposal. - -\subsection{Ergon: a reference implementation} -\label{sec:system:ergon} - -We implement the format as Ergon, an async gym with a -persistent recording substrate. Ergon hosts existing agent -frameworks (LangGraph, CrewAI, AutoGen, Claude Code) via -lightweight adapters and feeds RL trainers (TRL, VERL, -OpenRLHF) through format projections that preserve the full -trajectory underneath. Ergon writes streams synchronously -under Postgres transactions; the -\texttt{ergon export-rollout-card } command emits a -rollout card from any completed run, and the -Figure~\ref{fig:dashboard} dashboard is a reference reader -over any $\tau_E$-conformant archive. Ergon is one -implementation of the format: any framework matching the -specification (Appendix~\ref{app:system:format-spec}) can -publish rollout cards. Adoption is additive: a paper can ship -cards without changing its headline table, and later work can -re-analyse them under whatever convention is useful. The -paper still reports the scalar; it also publishes the rollout; -the convention that produced the scalar is separable, -inspectable, and replaceable. - -% ============================================================================ -\section{Experiments} -\label{sec:validation} - -% ---------------------------------------------------------------------------- -% Session 7 draft. Three subsections: -% 4.1 Research Questions --- RQ1 (cross-community reanalysis) + RQ2 -% (cross-harness reconciliation). Frames what §4 tests. -% 4.2 Experimental setup --- three setups: MiniF2F + Research Rubrics -% recorded natively; SWE-bench ingested from published submissions. -% Behavioural quantities compressed to a table. -% 4.3 Results and discussion --- RQ1 findings from MiniF2F + RR rollouts; -% RQ2 headline finding (convention accounts for ~4pp of 15.6pp gap); -% decomposition under Convention A and B. -% -% Placeholder numbers in \expnum{} pending: -% - Experiment A rollouts (MiniF2F + RR, flexible-decomposition agent) -% - Weekend 1 SWE-bench reconciliation pipeline -% -% §4.6 durability under faults --- REMOVED FROM BODY in v5.6.1. Moved to -% Appendix as Session 11 work. Body cites appendix briefly in §4.2. -% ---------------------------------------------------------------------------- - -\subsection{Research questions} -\label{sec:validation:questions} - -We evaluate the proposal with two experiments, each addressing -one of the two problems diagnosed in \S\ref{sec:problem}: -RQ1 targets the cross-community recording mismatch of -\S\ref{sec:problem:communities}, RQ2 targets the -convention-disagreement reconciliation of -\S\ref{sec:problem:variance}. Both use rollout cards as a -shared artefact against which we apply conventions post-hoc; -neither requires re-running agents. The experimental scope is a proof of concept. Two pairings in -RQ1 and one reconciliation in RQ2 are enough to establish that -the format is structurally richer than any single community's -canonical format. We do not claim that this two-pairing scope -exhausts the community pairs for which the format is -adequate. - -\paragraph{RQ1: Cross-community reanalysis.} Can a rollout -card recorded under one research community's native scorer be -consumed by a different community's scorer to recover a -quantity the original publication did not report? Positive -evidence would show that the format is rich enough to serve -communities whose questions differ from the recording -community's, directly from the rollout rather than by -re-generating trajectories under a new harness. - -\paragraph{RQ2: Cross-harness reconciliation.} Can -published trajectories from two harnesses reporting different -scores on the same benchmark be ingested into a common rollout -card format, re-graded under a uniform convention, and the -reported score gap decomposed into harness-attributable and -method-attributable components? Positive evidence would show -that rollout cards provide a substrate for making otherwise -untraceable reported-number disagreements auditable. - -\subsection{Setup} -\label{sec:validation:setup} - -The experiments use three task families. For the first two -(MiniF2F and Research Rubrics) we run a flexible-decomposition -agent natively in Ergon to produce rollouts; these exercise -RQ1. For the third (SWE-bench Verified) we ingest rollouts from -two published submissions (SWE-agent and Agentless, both on -GPT-4o); this exercises RQ2. The two experiments draw on six -behavioural quantities, each a canonical analysis of one of the -five communities of Sec.~\ref{sec:problem:communities}; -Appendix~\ref{app:quantities} catalogues the full set and we -compute three in \S\ref{sec:validation:results}. Ergon commits -every mutation synchronously -(Appendix~\ref{app:system:mutations}), so a mid-rollout crash -leaves the run graph in a well-defined partial state. - -\paragraph{Cross-community reanalysis (MiniF2F, Research -Rubrics).} A single \emph{flexible-decomposition agent} runs -on both task families with a shared backbone -(\expnum{model\_target TBD}) and a turn cap of -\expnum{N TBD}. Its system prompt describes four -subtask-decomposition tools (spawn, cancel, wait-on, and -report-result) mechanically, with no task-specific strategic -guidance, so observed decomposition structure comes from the -agent rather than the prompt. Ergon records trajectories -directly as rollout cards (Sec.~\ref{sec:system:format}). - -\textbf{MiniF2F} ($\sim$30 problems, Lean~4 theorem proving -\citep{zheng2022minif2f}) uses \texttt{lean\_repl} / -\texttt{lean\_check} and binary grading. -\textbf{Research Rubrics} ($\sim$30 questions, long-horizon -open-ended research) uses \texttt{web\_search} / -\texttt{read\_document} with rubric-weighted GPT-4o-mini -evaluation. - -For RQ1 we pair each task family with a community whose -native scorer was \emph{not} the one the task's designers had -in mind. MiniF2F, which we record for binary proof-success, -pairs with the MCTS-based training community -\citep{rstarmath2025, feng2024restmcts}, whose quantities -include abandonment ratio by tree depth and sub-goal -semantics at abandoned nodes. Research Rubrics, which we -record for scalar rubric scores, pairs with the fixed-role -MAS community \citep{cemri2025mast}, whose quantities include -per-agent role specialisation; we capture it via -sibling-embedding distance across workers the agent spawns -from the same parent. In each pair, the target quantity is -one the source scorer discards but the recorded rollout -carries. - -\paragraph{Cross-harness reconciliation (SWE-bench -Verified).} The paper's motivating gap is SWE-agent's 23.2\% -vs.\ Agentless's 38.8\% on GPT-4o-2024-05-13 (15.6pp). Both -submissions publish complete trajectories through -\texttt{swe-bench-submissions}; we ingest each into a rollout -card and re-grade under a uniform convention. The ingestion -is the main engineering step: SWE-agent ships per-instance -JSON with a flat conversation history and a denormalised -\texttt{trajectory} array, Agentless ships per-instance -Python-logger plaintext with -\texttt{ChatCompletion(\ldots)}~reprs across a seven-stage -pipeline. Each ingestion produces a drops manifest -(Appendix~\ref{app:reconciliation}) naming what the source -format could not carry, exercising the methodology of -Sec.~\ref{sec:system:drops}. - -One convention choice matters enough to foreground: the -treatment of \texttt{no\_generation} outcomes, where the -harness records no agent submission. SWE-agent produces 50 -such outcomes (10\% of Verified); Agentless produces 4 -(0.8\%). We report under two conventions. -\emph{Convention A} excludes no-generation cases from the -denominator (isolating attempts from non-attempts); -\emph{Convention B} includes them as zeros (what the -published headlines implicitly use). The choice surfaces a -harness-level decision that neither published paper's -headline makes visible. - -\subsection{Results} -\label{sec:validation:results} - -Results come in three parts: RQ1a recovers MCTS-community -quantities from MiniF2F rollouts, RQ1b recovers MAS-community -quantities from Research Rubrics rollouts, and RQ2 decomposes -the SWE-agent/Agentless gap on SWE-bench Verified. - -\paragraph{RQ1a: MiniF2F $\to$ MCTS-community quantities.} -We record \expnum{N TBD} MiniF2F rollouts under binary grading -and recover abandonment ratios as a function of containment -depth: at depth~1 decompositions, -\expnum{A\_1 TBD}\% are abandoned before completion; at -depth~2, \expnum{A\_2 TBD}\%; at depth~3, \expnum{A\_3 TBD}\%. -Clustering the assistant-text content at abandoned -nodes yields \expnum{K TBD} distinct sub-goal categories -(e.g.\ \expnum{category descriptions TBD}), matching the -negative-preference training signal rStar-Math's PPM uses -\citep{rstarmath2025}. We cannot derive either quantity from -the binary pass/fail the MiniF2F headline reports; we compute -both post-hoc from the rollout card's node and event streams -without re-running any proofs. - -\paragraph{RQ1b: Research Rubrics $\to$ MAS-community -quantities.} We record \expnum{N TBD} Research Rubrics -rollouts under rubric-score grading and compute per-agent -sibling embedding distance: \expnum{D TBD}\% of sibling pairs -have cosine distance above \expnum{threshold TBD}, indicating -role differentiation in the sense of MAST's duplicate-agent -failure mode \citep{cemri2025mast}. The rubric-scored headline -reports a scalar per question; we can compute the -role-specialisation analysis from the same rollouts, and -\expnum{Q TBD} of \expnum{N TBD} exhibit the duplicate-agent -pattern invisible in the rubric number. Neither -task family was designed for the community whose quantities -we recover, so we claim only that the relevant quantities are -\emph{computable} from the rollouts (which current practice -of publishing community-native aggregates denies), not -numerical parity with a native harness. - -\paragraph{RQ2: Cross-harness reconciliation on SWE-bench.} -Approximately \expnum{convention TBD}pp of the 15.6pp -published SWE-agent/Agentless gap on SWE-bench Verified -resolves to a single harness convention --- the treatment of -\texttt{no\_generation} outcomes, where SWE-agent produces 50 -such instances (10\% of Verified) and Agentless produces 4; -the remaining \expnum{M TBD}pp is method-attributable. Neither -publication's reported number makes this classification choice -visible. Table~\ref{tab:reconciliation} decomposes the gap: -under Convention~B (the published convention, treating -no-generation as zero) the re-graded gap is -\expnum{gap\_B TBD}pp; under Convention~A (excluding -no-generation from the denominator) the remaining -gap is \expnum{gap\_A TBD}pp, attributable to how each harness -elicits patches. We take the decomposition itself to be the -primary deliverable: a gap that published as a pure method -difference turns out to have a substantial harness-convention -component, invisible without the rollout-card substrate. - -\begin{table}[h] -\centering -\small -\caption{Decomposition of the 15.6pp published gap between -SWE-agent and Agentless (GPT-4o-2024-05-13, SWE-bench -Verified). Under Convention~B, no-generation outcomes count -as zeros (the published default); under -Convention~A, they are excluded from the denominator. -Ingestion drops manifests are in -Appendix~\ref{app:reconciliation}.} -\label{tab:reconciliation} -\begin{tabular}{@{}lcc@{}} -\toprule - & \textbf{SWE-agent} & \textbf{Agentless} \\ -\midrule -Published score & 23.2\% (116/500) & 38.8\% (194/500) \\ -Re-graded, Convention~B & \expnum{X TBD}\% & \expnum{Y TBD}\% \\ -Re-graded, Convention~A & \expnum{X' TBD}\% & \expnum{Y' TBD}\% \\ -\midrule -Published gap & \multicolumn{2}{c}{15.6pp} \\ -Convention-attributable (B $\to$ A delta) & \multicolumn{2}{c}{\expnum{C TBD}pp} \\ -Method-attributable (under A) & \multicolumn{2}{c}{\expnum{M TBD}pp} \\ -\bottomrule -\end{tabular} -\end{table} - -A second observation cuts the other way. Agentless at SHA -\texttt{5ce5888} defaults missing regression-test results to -\texttt{[0]*10000} rather than surfacing them as failures -(Appendix~\ref{app:survey}, Patterns~P1 and~P4), so instances -where its own pipeline errors out are charged against the -method. The silent-drop bias therefore runs in the wrong -direction to absorb the convention gap: if anything, it -\emph{amplifies} the 15.6pp. The audit pins -\texttt{agentless/test/run\_regression\_tests.py} at the -named SHA, and a reader can verify the \texttt{[0]*10000} -default in two clicks. That SWE-agent JSON and Agentless -plaintext-log formats both ingest into the same target with -documented drops manifests (Appendix~\ref{app:reconciliation}) -shows the rollout-card format accommodates heterogeneous -sources, not only data recorded natively. - -% ============================================================================ -\section{Related Work} -\label{sec:related} -% ---------------------------------------------------------------------------- -% Session 9: Draft per Section Spec row ``Sec.~5 Related Work''. Target 0.5 page. -% Content: Datasheets / Model Cards / Evaluation Cards as direct genre -% precedent (anchored in Sec.~3.3). Transform-variance literature: Biderman et al. -% 2024 (prompt-template 24.6pp), Dr. GRPO, verl #2165 (tokenization-channel -% divergence). DAPO as secondary instance citation. Existing Sec.~5 content -% (Gym/Gymnasium, PettingZoo/JaxMARL, agent frameworks, recursive toolkits, -% schema-alignment efforts AgentOhana/VerlTool/Agent Lightning) carries -% through compressed. -% ---------------------------------------------------------------------------- - -We position rollout cards against four adjacent threads: the -documentation-standards genre they extend, convention-variance -documentation in evaluation and training, the -environment-and-framework landscape Ergon slots into, and -schema-alignment efforts at training-input time. - -\paragraph{Genre precedent.} Rollout cards sit alongside -Datasheets for Datasets \citep{gebru2021datasheets} and Model -Cards for Model Reporting \citep{mitchell2019modelcards}: -structured artefacts published alongside a research object to -make downstream use legible. Sec.~\ref{sec:system:format} -adapts the pattern to agent trajectories. - -\paragraph{Convention variance in adjacent spaces.} -\citet{biderman2024} find 24.6pp variance on LLaMA-7B MMLU -across three popular harnesses driven by prompt-template -choice. On the training side, \citet{liu2025drgrpo} document -+7.3 to +15.7pp on AIME~2024 from a single GRPO -loss-aggregation convention, and verl -issue~\#2165~\citep{verl2165} documents tokenization-channel -divergence on Qwen3-4B GRPO with six independent -reproductions. Those works identify and fix specific -conventions; the present work proposes infrastructure for -making future disagreements of this kind auditable before -they calcify. - -\paragraph{Environment and framework landscape.} -Gym~\citep{brockman2016gym} and -Gymnasium~\citep{towers2024gymnasium} are the canonical -precedent for a shared evaluation interface in RL; Ergon makes -the same move at a higher level of abstraction, standardising -recording, rollout execution, and trainer integration for -decomposed long-horizon work. -PettingZoo~\citep{terry2021pettingzoo} and -JaxMARL~\citep{rutherford2024jaxmarl} extend Gym to -fixed-population multi-agent with joint actions; dynamic -population, asynchronous dispatch, mid-rollout cancellation, -and non-tree dependency sit outside that paradigm. Agent -frameworks (LangGraph~\citep{langgraph2024}, -CrewAI~\citep{crewai2024}, AutoGen~\citep{wu2024autogen}, -Claude Code~\citep{claudecode2024}) and inference-time -recursive toolkits (ReDel~\citep{zhu2024redel}, -RLM~\citep{zhang2025rlm}, -AgentOrchestra~\citep{agentorchestra2025}) run under Ergon's -substrate with automatic recording. - -\paragraph{Schema alignment efforts.} Recent -surveys~\citep{yehudai2025agenteval, cemri2025mast, -yang2025agentprotocols} flag fragmentation across agent -trajectory formats, and unification efforts address it at -training time: -AgentOhana~\citep{zhang2024agentohana} standardises -trajectories into a unified training loader, -VerlTool~\citep{jiang2025verltool} aligns tool-integration -logic across agentic RL codebases, and Agent -Lightning~\citep{luo2025agentlightning} decouples LLM -generation from agent logic. Those proposals unify -\emph{training inputs}; we propose a \emph{publication -format}, with drops manifests -(Sec.~\ref{sec:system:drops}) supplying a per-format account -of what each canonical shape cannot represent. - -% ============================================================================ -\section{Discussion} -\label{sec:discussion} -% ---------------------------------------------------------------------------- -% Session 9: Draft per Section Spec row ``Sec.~6 Discussion''. Target 0.75 page. -% Content beats: -% (1) Policy paragraph --- publishing rollouts orders of magnitude cheaper -% than hosting models, CERN/genomics/astronomy precedents, opt-in -% per-researcher adoption -% (2) Honest-limitation paragraph --- rollout cards enable DETECTION not -% forced RESOLUTION; analogy to Datasheets/Model Cards (documentation -% standards don't force methodological convergence) -% (3) Dual-role community framing --- five Sec.~2.2 communities illustrate -% general claim AND are specifically the five we ship projections for; -% pattern generalises to ITP / agentic RAG / robot learning / -% embodied agents but concrete support requires further projections -% (4) Further limitations --- coverage representative, training-side -% citation-based not reproduced, case studies single-analyst -% (5) Future work --- transform-provenance tooling; shared transform -% libraries; reconciliation services; projections for additional -% communities. -% ---------------------------------------------------------------------------- - -We discuss four aspects of the proposal in turn: publication -cost and the adoption path, the distinction between detection -and resolution, scope limitations of the present work, and -future directions. - -\paragraph{Publication cost and adoption.} Publishing rollout -cards is orders of magnitude cheaper than hosting models: a -rollout card for a completed agent run is at most a few -megabytes of text and event records, where a model checkpoint -is tens to hundreds of gigabytes. The precedent for domain-wide -rollout publishing exists elsewhere: CERN Open -Data~\citep{cernopendata} releases detector-event records from -LHC experiments; genomic consortia (1000 Genomes, UK Biobank) -publish per-individual read-level data alongside aggregate -findings; astronomy surveys (SDSS, Gaia) publish -observation-level exposures with derived catalogues. In each -case the shared artefact is the observation, not only the -analysis. Agent research can make the same move. Adoption is -opt-in per researcher and requires no coordinated ecosystem -pivot: a paper that ships a rollout card alongside its headline -number loses nothing and gains auditability; a reader who wants -to reanalyse can do so against the rollout without asking for -the original harness. - -\paragraph{Detection over resolution.} Rollout cards let a -reader \emph{detect} convention disagreement. They do not by -themselves force a \emph{resolution}. A second researcher -applying a different convention to the same rollouts will -generally produce a different number than the original paper, -and we consider this a feature: the disagreement becomes -legible, because both conventions' drops manifests are -publishable and comparable, and reconcilable, because a -uniform convention can be applied to both. The Datasheets and -Model Cards analogy holds here too: documentation standards -make practice legible; they do not force methodological -convergence. We expect a field that adopts rollout cards to -have more transparent disagreements than it has today. - -\paragraph{Limitations.} Coverage is representative: we draw -five communities from a long tail, audit 50 repositories from -a much larger population, and select 37 variance pairs from -the subset where documentation made comparison possible. We -cite the training-side convention-variance evidence -(Dr.~GRPO, verl~\#2165) rather than reproduce it here. The -cross-community reanalysis is single-analyst: we pair each -task family with a single target community rather than a -multi-community panel. The cross-harness reconciliation -demonstrates the format on two published submissions on one -benchmark; we leave generalisation across benchmarks and model -variants to future work. -We claim the general pattern (conventions applied without -rollouts make disagreement untraceable) is robust across -these scope bounds, but not that every specific number -generalises. - -\paragraph{Future work.} Two directions. First, -convention-provenance tooling: a rollout card plus its drops -manifest is a consumable artefact for automated variance -analysis; a tool that ingests both and flags convention -divergences would make the SWE-bench finding of -Sec.~\ref{sec:validation:results} routine rather than ad hoc. -Second, training-side rollout cards: -Sec.~\ref{sec:problem:variance}'s training-side evidence -implies an analogue at the gradient-computation layer where -the loss and advantage rules play the role of the reporting -convention; we have not worked out what that looks like. - -% ============================================================================ -\section{Conclusion} -\label{sec:conclusion} -% ============================================================================ - -Agent research publishes reported numbers without the rollouts -those numbers were computed from, and the resulting coupling -between recording and reporting produces both cross-community -fragmentation and cross-harness variance that cannot be -reconciled after the fact. We proposed \emph{rollout cards}: -an event-sourced publication format in which the rollout -itself is the research artefact, and a later researcher can -apply any reporting convention (grading script, aggregation -rule, training loss) post-hoc. We introduced drops manifests as a -paper-level methodological concept accompanying any reduction -of a rollout to an aggregate, and presented Ergon as a -reference implementation at the intersection of the agent and -RL stacks. Two experiments demonstrated the proposal: -cross-community reanalysis on MiniF2F and Research Rubrics -rollouts showed that recorded trajectories carry quantities -the recording community's scorer discarded, and -cross-harness reconciliation on SWE-bench Verified showed that -a substantial fraction of a widely-published score gap -resolves to a harness convention invisible in the reported -numbers. If the field adopts the format, reported-number disagreements -become legible and auditable. That is the tractable -precondition we argue for: an ecosystem in which the field can -compare cross-community and cross-harness results on their -merits rather than on the conventions of their producers. - -% ============================================================================ -% BIBLIOGRAPHY -% ============================================================================ -\bibliographystyle{plainnat} -\bibliography{references} - -% ============================================================================ -% APPENDICES -% ============================================================================ -\appendix - - -% ---------------------------------------------------------------------------- -% Appendix A --- 50-repo Failure-Handling Survey. -% Source of record: ergon_survey/SURVEY_v4.md (commit-pinned edition, April 2026). -% Structure: selection, methodology, rubric, distribution, per-repo entries -% (one-liner each; top-11 score-3 with 2-3-sentence writeup), verification -% process, coverage snapshot. -% ---------------------------------------------------------------------------- - -\section{50-repo Failure-Handling Survey} -\label{app:survey} - -This appendix reports the code-level evidence underlying the -failure-surfacing claim in Sec.~\ref{sec:problem}. The final -sample contains 50 in-scope repositories selected by -category-stratified purposive sampling across the code paths by -which LLM-agent and LLM-training papers publish benchmark -numbers, training metrics, or leaderboard ranks. Each repository -was shallow-cloned at a pinned commit SHA during the April 2026 -audit window and scored against a seven-pattern failure-handling -rubric. Every source-level claim below is backed by a -commit-pinned GitHub permalink in the supplementary survey -document \texttt{SURVEY\_v4.md}; pinned SHAs are listed in -\S\ref{app:survey:shas}. - -\subsection{Repository selection} -\label{app:survey:selection} - -\paragraph{Sampling frame and operational scope filter.} -The survey uses \emph{category-stratified purposive sampling} -over the code pipeline through which an LLM-agent paper -publishes a benchmark number. A repository is in-scope if and -only if, at its pinned SHA, it contains code that (a)~is -actively maintained (last commit within the past 12 months), -(b)~produces a benchmark number, training-pipeline metric, or -leaderboard rank that a published paper would cite, and -(c)~implements the result aggregation itself rather than -delegating all aggregation to a separately-versioned downstream -harness. Star-count was not used as a threshold; verified -in-scope repositories range from $\sim\!150$ stars (SciCode -164, MLAgentBench 320) to $\sim\!40$k+ (FastChat, aider), -demonstrating that the in-scope/canonical-harness filter -dominates any star-based floor. Operational scope is enforced -by two exclusions, documented as examples rather than -concealed: Online-Mind2Web was on the candidate list but -excluded after repeated clone errors prevented SHA pinning; -OpenHands was spot-checked (SHA \texttt{3b17f27}) and -documented OUT-OF-SCOPE because its evaluation harness lives in -a separately-versioned repository -(\texttt{github.com/OpenHands/benchmarks}) and the main repo -contains no result-aggregation, scoring, or denominator-handling -code. This is the operational scope filter working as intended: -OpenHands is the foundation layer (analogous to how PyTorch is -the foundation for training frameworks) and is correctly -excluded by criterion~(c). - -\paragraph{Sample composition.} -The 50 repositories are grouped into five strata chosen to cover -the major places where agent-research numbers are produced: -\emph{(i)} SWE-bench-family harnesses and agent scaffolds; -\emph{(ii)} RL and agentic-RL training frameworks; -\emph{(iii)} general evaluation harnesses, LLM-as-judge systems, -and leaderboard infrastructure; \emph{(iv)} web, GUI, mobile, -scientific-agent, and ML-engineering benchmarks; and -\emph{(v)} function-calling, multi-agent, and general -agent-scaffold repositories. Selection was driven by operational -role rather than popularity: a repository was included when it -contained aggregation, scoring, denominator handling, failure -handling, reward computation, or training-metric code that could -affect a number cited in a paper. - -\paragraph{Repository strata.} -\emph{(i)} SWE-bench-family harnesses and agent scaffolds -(SWE-bench, SWE-agent, mini-swe-agent, live-swe-agent, -SWE-bench\_Pro-os, SWE-smith, Agentless, aider); -\emph{(ii)}~RL training frameworks (TRL, verl, OpenRLHF, rllm, -slime, ART, Agent-R1, RAGEN, MARTI, MATPO, RL-Factory, -Trinity-RFT, ms-swift, verifiers, open-r1); \emph{(iii)}~general -eval harnesses and LLM-as-judge leaderboards (openai/evals, -simple-evals, SciCode, lm-evaluation-harness, lighteval, -inspect\_ai, FastChat/MT-Bench + Chatbot Arena, HELM, ragas, -deepeval, MTEB, promptfoo, BIG-bench); \emph{(iv)}~web/GUI, -mobile, and ML-engineering / scientific-agent benchmarks -(WebArena, VisualWebArena, OSWorld, android\_world, Mind2Web, -MLE-bench, MLAgentBench, ScienceAgentBench); and \emph{(v)}~% -function-calling and general multi-agent / scaffold repositories -(BFCL/gorilla, ToolBench, AgentBench, AgentBoard, tau-bench, -Self-rewarding-reasoning-LLM). The final sample is 50 in-scope -repositories, with two exclusions (Online-Mind2Web, OpenHands) -documented as scope-filter exemplars rather than concealed. - -\subsection{Audit methodology} -\label{app:survey:method} - -Each repository was shallow-cloned once into -\texttt{ergon\_survey/clones/\{repo\}} and pinned at \texttt{git -rev-parse HEAD}. The pinned SHA list in -\S\ref{app:survey:shas} is the verification baseline for every -permalink. Every citation in this appendix -resolves to -\texttt{github.com/\{owner\}/\{repo\}/blob/\{SHA\}/\{path\}\#L\{start\}-L\{end\}} -against that SHA. Each claim identifies the code path that -controls failure handling, denominator construction, reward -coercion, or result aggregation for a number the repository -can publish. Two representative per-repo reports are included -verbatim in the supplementary materials to illustrate the -template. - -\subsection{Severity rubric: seven patterns of silent drop} -\label{app:survey:rubric} - -Every repository was scored 0--3 against a rubric defined by -seven recurring code patterns: -\emph{(P1)} \texttt{None}-to-\texttt{0.0} reward coercion -(reward-function exception silently yields a numeric zero that -enters aggregation alongside genuine zero-reward rollouts); -\emph{(P2)} variance-based rollout filters that discard entire -rollout groups with zero std (collapse with the remaining group -in published aggregates); \emph{(P3)} -save-on-clean-exit patterns bypassed by -\texttt{KeyboardInterrupt} or early-exit cost-killers, removing -instances from the submission file rather than the denominator; -\emph{(P4)} survivor denominators --- reporting -\texttt{sum(scores) / len(scores)} over instances that reached -the aggregation step, with the pre-aggregation drop count not -preserved; \emph{(P5)} multi-turn N-destroys-1-to-N-1 patterns, -where a mid-rollout tool-call exception discards the entire -trajectory including its already-completed turns; \emph{(P6)} -LLM-judge regex-miss-to-fixed-score, where a judge returning -an unparseable response is coerced to a specific numeric score -indistinguishable from a legitimate one (e.g.\ \texttt{0.0}, -\texttt{min(choice\_scores)}, \texttt{NOT\_ATTEMPTED}); and -\emph{(P7)} outcome-as-ground-truth SFT filtering, where a -downstream supervised dataset is built from only those -trajectories that scored correctly on the biased harness, -propagating the bias across a model-generation boundary. A -repository scores 3 if a single pattern is catastrophic and -observable at the headline output (the failure changes the -number a paper would cite); 2 if one or more patterns are -present but the reader cannot tell from the published CSV; 1 if -failures are logged but excluded from the denominator with no -per-category counter; 0 if all failures are surfaced alongside -metrics. No repository in the survey defaults to score 0. - -\subsection{Score distribution} -\label{app:survey:distribution} - -\begin{table}[h] -\centering -\small -\caption{50-repo severity scores. No repository defaults to -surfacing failures alongside metrics. OpenHands is documented -OUT-OF-SCOPE (no in-repo aggregation harness; benchmarks live -in a separately-versioned repository) and not counted in the -50. Simple-evals appears in both score-3 (SimpleQA subset) and -score-1 (non-SimpleQA subset); the $11{+}31{+}9{=}51$ listings -correspond to 50 unique repositories.} -\label{tab:survey:distribution} -\begin{tabular}{@{}clp{0.55\linewidth}@{}} -\toprule -\textbf{Score} & \textbf{Count} & \textbf{Repositories} \\ -\midrule -3 & 11 & SWE-bench, openai/evals, simple-evals (SimpleQA), -VisualWebArena, ToolBench, Self-rewarding-reasoning-LLM, -PrimeIntellect verifiers, Trinity-RFT, ms-swift (GRPO), BFCL -(gorilla), FastChat (MT-Bench + Chatbot Arena) \\ -\addlinespace -2 & 31 & Agent-R1, MARTI, SWE-bench\_Pro-os, RAGEN, OpenRLHF, -TRL, ART, slime, tau-bench, rllm, live-swe-agent, SWE-agent, -mini-swe-agent, MATPO, verl, AgentBoard, AgentBench, -RL-Factory, SWE-smith, lm-evaluation-harness, WebArena, -MLAgentBench, MLE-bench, lighteval, Agentless, aider, Mind2Web, -open-r1, ragas, MTEB, promptfoo \\ -\addlinespace -1 & 9 & SciCode, inspect\_ai, ScienceAgentBench, android\_world, -OSWorld (harness), simple-evals (non-SimpleQA subset), HELM, -BIG-bench, deepeval \\ -\addlinespace -0 & 0 & --- \\ -\bottomrule -\end{tabular} -\end{table} - -Inspect AI (score 1) is the positive control referenced in -Sec.~\ref{sec:problem}: its default -\texttt{fail\_on\_error=True} surfaces per-sample errors, but -the commonly-used \texttt{fail\_on\_error=False} opt-in -degrades silently with no per-category counter -(\texttt{inspect\_ai/\_eval/task/error.py:L26} and -\texttt{inspect\_ai/scorer/\_metrics/accuracy.py:L33} at SHA -\texttt{36231d6}). - -\subsection{Per-repo entries} -\label{app:survey:entries} - -We summarise each repository's score with a one-line evidence -pointer; the eleven score-3 repositories receive a short -2--3-sentence writeup naming the specific code site(s) and -user-visible consequence. All paths are relative to the repo -root at the pinned SHA (\S\ref{app:survey:shas}). Full -permalinks are in \texttt{SURVEY\_v4.md}. - -\paragraph{Score-3 repositories (11).} - -\textbf{SWE-bench.} The grading loop aggregates over -\texttt{instances\_to\_run}, filtered before aggregation -(\texttt{swebench/harness/run\_evaluation.py:L465-L470}), while -empty patches are documented as filtered -pre-submission (\texttt{docs/faq.md:L39}). A container-level -infrastructure failure stays in the denominator as a fail, but -an empty-patch instance is removed from the denominator -altogether --- and no output field separates the two cases in -the published submission. - -\textbf{openai/evals.} -\texttt{backoff.on\_predicate} at -\texttt{evals/utils/api\_utils.py:L10-L22} retries transient -predicates indefinitely with no \texttt{max\_tries}, so long -runs silently absorb retry-exhaust events rather than surfacing -them. The MMMU eval treats errors as wrong answers -(\texttt{elsuite/mmmu/eval.py:L158}) and the modelgraded -classifier coerces \texttt{INVALID\_STR} to -\texttt{min(choice\_scores)} -(\texttt{elsuite/modelgraded/classify\_utils.py:L99-L101}): -three distinct P6 patterns in one library. - -\textbf{simple-evals (SimpleQA subset).} The regex -fallback at \texttt{simpleqa\_eval.py:L126} defaults unparseable -responses to \texttt{NOT\_ATTEMPTED}, which the headline metric -(\texttt{accuracy\_given\_attempted = correct / (correct + -incorrect)}) then structurally excludes -(\texttt{simpleqa\_eval.py:L169-L176}). A model that hedges on -every ambiguous question looks arbitrarily accurate under this -metric --- NOT\_ATTEMPTED tasks do not appear in the -denominator. - -\textbf{VisualWebArena.} An \texttt{assert "correct" -in response} at -\texttt{browser\_env/helper\_functions.py:L606} raises on -unparseable judge output, causing the outer try/except at -\texttt{run.py:L453-L461} to drop the entire task. Image-fetch -errors at \texttt{image\_utils.py:L44} propagate through the -same path. Dropped tasks are absent from the output, not -counted as failed. - -\textbf{ToolBench (9/9 \textsc{verified}).} The DFS -search code at \texttt{DFS.py:L84-L91} randomly promotes a -fraction of \texttt{give\_up\_node} trajectories to -\texttt{valid\_data=True}, and SFT preprocessing at -\texttt{preprocess/preprocess\_toolllama\_data.py:L44-L45} -filters on \texttt{valid\_data} alone --- so ToolLLaMA trains -on the promoted failures. This is the paper's paradigm example -of pattern~P7 (outcome-as-ground-truth SFT). - -\textbf{Self-rewarding-reasoning-LLM (4/4 -\textsc{verified}).} -\texttt{infer\_math/}\allowbreak\texttt{reward\_labeling.py:L1612-L1622} grants -correctness against the ground-truth answer on the -\emph{initial} chain-of-thought; self-correction attempts must -match that \texttt{first\_reward} to be counted -(\texttt{process\_prompt\_turn3.py:L24-L62}), with a hard cap -$N=3$. There is no \texttt{try/except} anywhere in the -generation module (\texttt{gen\_hf.py}): any infra failure -silently removes a rollout. - -\textbf{PrimeIntellect verifiers (5/5 -\textsc{verified}).} Reward-function exceptions at -\texttt{rubric.py:L144-L158} yield \texttt{0.0}, and group -reward-function exceptions at \texttt{rubric.py:L208,~L217} -yield \texttt{[0.0]*N}. The errored zeros are then included in -the group-mean advantage at \texttt{rubric.py:L325-L333} -(\texttt{avg\_reward = sum(aggregated\_rewards) / num\_states}; -\texttt{t["advantage"] = state["advantage"]}), so an -infrastructure error in one rollout biases the policy gradient -for the other rollouts in its group. - -\textbf{Trinity-RFT.} -\texttt{asyncio.TimeoutError} at -\texttt{trinity/explorer/scheduler.py:L214-L216} is packaged -into \texttt{Status(ok=False,~metrics=list(),~message=\ldots)}, -but the consumer path at \texttt{scheduler.py:L532-L602} -(\texttt{get\_results}) never inspects \texttt{status.ok} --- -errored payloads flow into training with \texttt{ok=False} -undetected. The over-rollout cancellation grace period -(\texttt{scheduler.py:L559-L572}, 30\,s default from -\texttt{config.py:L147}) means timeouts can also truncate -sub-tasks mid-run (\texttt{scheduler.py:L490-L506}) without -surfacing the partial-completion to the rollout log. - -\textbf{ms-swift (GRPO) (8/8 \textsc{verified}).} -None-rewards are lifted to NaN at -\texttt{swift/rlhf\_trainers/grpo\_trainer.py:L359, L367, -L379}, then aggregated with \texttt{.nansum(dim=1)} -(\texttt{grpo\_trainer.py:L473}); DAPO's -\texttt{max\_resample\_times} revert (\texttt{grpo\_trainer.py:% -L684-L733}) and \texttt{overlong\_filter} mask-zeroing -(\texttt{grpo\_trainer.py:L1123-L1129}) both silently remove -rollouts from the gradient. The LLM-judge path -(\texttt{rewards/rm\_plugin.py:L216-L226}) coerces regex-parse -failures to \texttt{0.0} (pattern P6). - -\textbf{BFCL / gorilla.} The weighted overall -accuracy formula in -\texttt{berkeley-function-call-leaderboard/bfcl\_eval/% -eval\_checker/eval\_runner\_helper.py:L509-L519} applies -$[10,10,10,30,40]$ weights over category runners that each -silently decrement \texttt{correct\_count} on parse failure -while leaving the denominator \texttt{len(model\_result)} -unchanged (per-category dispatch at \texttt{eval\_runner.py:% -L668}). \texttt{eval(func\_call)} exceptions become literal -\texttt{"Error during execution: \ldots"} strings -(\texttt{multi\_turn\_eval/multi\_turn\_utils.py:L97-L98}), -scored as a failed call without category attribution. - -\textbf{FastChat (MT-Bench + Chatbot Arena).} FastChat is the -reference implementation for two of the most-cited public LLM -evaluation numbers: MT-Bench (LLM-as-judge single-turn and -multi-turn scoring) and Chatbot Arena (pairwise Elo ranking). A -GPT-4 or Claude judge whose response fails to match the -\texttt{[[score]]} or \texttt{[score]} regex is coerced to -\texttt{rating = -1} (P6) at -\texttt{fastchat/llm\_judge/common.py:L175-L187}; MT-Bench -aggregation then filters \texttt{df[df["score"] != -1]} before -computing the per-model mean (P2+P4) at -\texttt{show\_result.py:L20}. The pairwise path is structurally -identical: parse-failure becomes \texttt{winner = "error"} -(\texttt{common.py:L282-L304}), filtered out before Elo -computation (\texttt{show\_result.py:L49}; -\texttt{elo\_analysis.py:L49-L92}). A rank swap driven by -differential parse-failure rates across judged models is not -recoverable from the headline MT-Bench or Arena Elo number --- -the attrition count is not emitted. At SHA \texttt{587d5cf}. - -\paragraph{Score-2 repositories (31, one-liner each).} - -\begin{description}[leftmargin=0pt,itemindent=0pt,labelindent=0pt,labelsep=0.4em,itemsep=2pt,topsep=2pt,parsep=0pt,font=\normalfont\bfseries] -\sloppy -\item[SWE-agent:] cost/kill \texttt{preds.unlink()} on -\texttt{early\_exit}/\texttt{None} removes instances from -\texttt{preds.json} -(\texttt{sweagent/run/run\_batch.py:L397-L401}). - -\item[mini-swe-agent:] \texttt{finally: save()} at -\texttt{src/minisweagent/run/}\allowbreak\texttt{benchmarks/swebench.py:L171} -is bypassed by \texttt{KeyboardInterrupt} (closed issue \#329); -streaming save at \texttt{default.py:L94}. - -\item[live-swe-agent:] configs never set \texttt{output\_path} -(\texttt{README.md:L71}). - -\item[SWE-bench\_Pro-os:] \texttt{None} return at -\texttt{evaluation/swe\_bench\_pro\_eval.py:L346-L349} is -collapsed to \texttt{False} by callers at \texttt{:L490-L505, -L571}. - -\item[AgentBench:] accuracy denominator is -\texttt{result.error is None} only -(\texttt{src/assigner.py:L368-L372}). - -\item[AgentBoard:] timeouts write to \texttt{error.txt} and -never \texttt{scores.append} -(\texttt{agentboard/tasks/webbrowse.py:L275-L279}). - -\item[tau-bench:] errors yield stubs with no retry -(\texttt{tau\_bench/run.py:L89-L96}). - -\item[SWE-smith:] broad \texttt{except} at -\texttt{scripts/collect\_trajs.py:L77-L79}; -\texttt{random.sample} cap at -\texttt{scripts/combine\_trajs.py:L86-L91}. - -\item[TRL:] \texttt{None}$\to$NaN$\to$\texttt{nansum} at -\texttt{trl/trainer/grpo\_trainer.py:L1228-L1230, L2132}. - -\item[verl:] PRIME reward timeouts$\to$\texttt{0.0} at -\texttt{verl/workers/reward\_manager/}\allowbreak\texttt{prime.py:L37-L42, L83}; -missing \texttt{return\_exceptions=True} at -\texttt{verl/experimental/agent\_loop/}\allowbreak\texttt{agent\_loop.py:L603} -(closed issue \#5956). - -\item[OpenRLHF:] remote RM -exception$\to$\texttt{reward=None} -(\texttt{openrlhf/utils/agent.py:L298-L299}; open issue -\#1139). - -\item[rllm (Berkeley Sky):] no-valid-trajectory episodes -dropped at \texttt{rllm/engine/}\allowbreak\texttt{agent\_workflow\_engine.py:L266}; -\texttt{dropped\_episodes} never written into -\texttt{DataProto.meta\_info} at \texttt{:L249} (open issue -\#382). - -\item[slime:] oversampling discard + zero-std filter at -\texttt{slime/rollout/sglang\_rollout.py:L449}. - -\item[ART:] \texttt{drop\_zero\_advantage\_trajectories=True} -default at \texttt{src/art/preprocessing/tokenize.py:L158}. - -\item[Agent-R1:] length-truncated trajectories published as -complete at -\texttt{agent\_r1/agent\_flow/agent\_env\_loop.py:L120-L129}. - -\item[RAGEN:] variance filter skips whole training step at -\texttt{ragen/trainer/}\allowbreak\texttt{agent\_trainer.py:L1054-L1056}. - -\item[MARTI:] dynamic filter drops saturated groups at -\texttt{marti/trainer/ppo\_trainer.py:L481-L510}. - -\item[MATPO:] MCP + judge failures$\to$reward 0.0 at -\texttt{verl/tools/mcp\_tool.py:L137, L150} and -\texttt{llm\_judge.py:L265, L312, L356}. - -\item[RL-Factory:] malformed tool -JSON$\to$\texttt{continue} at -\texttt{verl/workers/rollout/}\allowbreak\texttt{sglang\_rollout/}\allowbreak\texttt{sglang\_rollout.py:L914-L916}; -PRIME timeouts$\to$0.0 at \texttt{prime.py:L141-L146}. - -\item[lm-evaluation-harness:] left-truncate with -\texttt{eval\_logger.warning} only -(\texttt{lm\_eval/models/huggingface.py:L1360-L1368}; closed -issues \#3419, \#3352, \#3161, \#1323). - -\item[WebArena:] outer \texttt{except Exception: -log-and-continue} at \texttt{run.py:L217-L364}; headline -\texttt{sum(scores) / len(scores)} over survivors at -\texttt{run.py:L365}; \texttt{env.step} swallow with -\texttt{terminated=False} on infra failure at -\texttt{browser\_env/envs.py:L239-L248}. - -\item[MLAgentBench:] subprocess -crash$\to$\texttt{"EnvError"} string at -\texttt{low\_level\_actions.py:L181-L220} + -\texttt{environment.py:L328-L334}; hard-coded magic baselines -at \texttt{plot.py:L249-L274}; fresh random GT per call at -\texttt{fathomnet/eval.py:L11}. - -\item[MLE-bench:] aggregation \texttt{pad\_missing=False} -drops incomplete seeds at -\texttt{experiments/aggregate\_grading\_reports.py:L69-L135}; -\texttt{MedalInfo} logic at -\texttt{mlebench/grade\_helpers.py:L123-L133}. - -\item[lighteval:] LiteLLM returns empty -\texttt{LitellmModel}\allowbreak\texttt{Response()} on Azure -content-filter and retry-exhaust -(\texttt{litellm\_model.py:L243, L254}); empty response scored 0 -at \texttt{metrics\_sample.py:L151-L152}; left-truncate prompts -without \texttt{truncated\_}\allowbreak\texttt{tokens\_count} at -\texttt{vllm\_model.py:L374-L397}. - -\item[Agentless:] missing regression-test results default to -\texttt{[0]{*}10000} at -\texttt{agentless/test/run\_regression\_tests.py} (P1+P4), -collapsing absent results into a vector of 10k zeros; -\texttt{\_post\_process\_multifile\_repair} returns an empty -tuple on exception, and SWE-bench filters empty patches -pre-submission (\texttt{docs/faq.md:L39}), removing the -instance from the denominator. Directly relevant to -\S\ref{sec:validation}'s RQ2 reconciliation of the -SWE-agent/Agentless 15.6pp gap (at SHA \texttt{5ce5888}). - -\item[aider:] benchmark-runner broad \texttt{except} at -\texttt{benchmark/benchmark.py:L666-L676} stores the exception -as \texttt{\{"exception": \ldots\}} row; aggregation at -\texttt{:L502-L512} divides by \texttt{len(rows)}, so -exception rows contribute 1 to the denominator and 0 to the -numerator without surfacing the infra-flake count. - -\item[Mind2Web:] \texttt{IndexError} on out-of-bounds choice -index caught at -\texttt{src/action\_prediction/metric.py:L203-L209} -(\texttt{logger.info}-only), falls through to score-0 + kept in -denominator (P1); \texttt{src/action\_prediction/}% -\texttt{dataloader.py:L277} applies a train-only positive-% -candidate filter, creating a train/eval data-distribution -asymmetry (P7). - -\item[open-r1:] E2B broad \texttt{except} at -\texttt{src/open\_r1/rewards/code\_providers.py:L107-L112} -coerces sandbox exceptions to \texttt{[0.0]{*}len(scripts)} -rewards with print-only surface; MorphProvider identical -pattern at \texttt{:L248-L249}; IOI/Codeforces paths enter -TRL's \texttt{None}$\to$\texttt{nansum} pipeline via -\texttt{:L396-L397, L449-L450}. - -\item[ragas:] Pydantic parser fallback at -\texttt{ragas/prompt/pydantic\_prompt.py:L315-L334} (self-fix -loop raises \texttt{RagasOutputParserException}); executor -broad \texttt{except} returns \texttt{float("nan")} at -\texttt{ragas/executor.py:L64-L86}; \texttt{safe\_nanmean} at -\texttt{ragas/utils.py:L46-L55} silently shrinks the -denominator by the NaN count across faithfulness, answer-% -relevancy, and context-precision metrics. - -\item[MTEB:] leaderboard aggregate filters out models with any -NaN task score at \texttt{mteb/leaderboard/\_create\_table.py:% -L136-L149} (P4 selection bias); per-language aggregates at -\texttt{mteb/results/task\_result.py:L526-L563} compute over -produced scores with no attempt-vs-score coverage counter. - -\item[promptfoo (TypeScript):] any LLM-judge parse failure -coerced to \texttt{\{pass: false, score: 0\}} at -\texttt{src/matchers/llmGrading.ts:L499, L515} (P6); fail -helpers at \texttt{src/matchers/shared.ts:L25-L38} return the -same hardcoded score indistinguishably from legitimate zeros; -provider-error path at \texttt{src/evaluator.ts:L1046-L1051} -produces \texttt{score: 0} without a \texttt{providerErrored} -flag. -\end{description} - -\paragraph{Score-1 repositories (9, one-liner each).} - -\begin{description}[leftmargin=0pt,itemindent=0pt,labelindent=0pt,labelsep=0.4em,itemsep=2pt,topsep=2pt,parsep=0pt,font=\normalfont\bfseries] -\sloppy -\item[SciCode:] partial-submission aggregation at -\texttt{eval/scripts/}\allowbreak\texttt{test\_generated\_}\allowbreak\texttt{code.py:L125-L127}. - -\item[inspect\_ai:] \texttt{fail\_on\_error=True} by default -(\texttt{\_eval/task/error.py:L26}), but -\texttt{fail\_on\_error=False} opt-in degrades silently -(\texttt{scorer/\_metrics/accuracy.py:L33}). - -\item[ScienceAgentBench:] swallows -\texttt{EvaluationError/BuildImageError/Exception} -(\texttt{evaluation/harness/run\_evaluation.py:L118-L136}); -discards \texttt{timed\_out} flag (\texttt{:L113}); GPT-4o -judge regex-miss$\to$score 0 at -\texttt{gpt4\_visual\_judge.py:L70-L72}. - -\item[android\_world:] \texttt{np.nan} + -\texttt{DataFrame.mean} asymmetric drop at -\texttt{task\_evals/task\_eval.py:L249-L259} and -\texttt{android\_world/suite\_utils.py:L544-L558, L681-L698}. - -\item[OSWorld (harness):] bare-\texttt{except} ``Time -limit exceeded'' at \texttt{run.py:L205-L218}; -\texttt{env.evaluate()} \texttt{FileNotFoundError}$\to$0 at -\texttt{desktop\_env/desktop\_env.py:L485-L487}; -Verified-subset non-comparability disclaimer at -\texttt{README.md:L36}. - -\item[simple-evals (non-SimpleQA subset):] honest grading on -multi-choice, regex miss$\to$wrong on GPQA/MMLU (see -Appendix~\ref{app:variance-catalogue}, Smoking-Gun~\#2). - -\item[HELM:] \texttt{Stat.add(None)} silently skips None -results at \texttt{src/helm/benchmark/metrics/statistic.py:L35}; -judge parse returns \texttt{None} at \texttt{gpt4\_audio\_}% -\texttt{critique\_metrics.py:L70-L72} and is absorbed by the -\texttt{Stat.add(None)} skip. The per-scenario attrition rate -is recoverable from the full output JSON but is not in the -headline report (at SHA \texttt{83bde5c}). - -\item[BIG-bench:] aggregate metric computation at -\texttt{bigbench/api/results.py:L560-L576} uses -\texttt{statistics.mean(d[m] for d in per\_task if m in d)}, -silently shifting the denominator by the count of tasks that -did not produce metric \texttt{m}. Per-task results are -preserved in raw output so the shift is recoverable, but the -headline mean does not emit a coverage counter (at SHA -\texttt{092b196}). - -\item[deepeval:] \texttt{JSONDecodeError} on LLM-judge output -re-raised as \texttt{ValueError} at -\texttt{deepeval/utils.py:L405-L413} and caught broadly by the -test-runner; \texttt{safe\_a\_measure} at -\texttt{deepeval/progress\_context.py:L299-L305} catches any -metric-evaluation exception. Per-test failure messages are -surfaced but parse-failure rates are not aggregated into a -headline counter (at SHA \texttt{f917b5a}). -\end{description} - -\subsection{Verification} -\label{app:survey:verification} - -Every source-level claim in this appendix is tied to a pinned -commit SHA and a line-level permalink in the supplementary -survey document. Claims were retained only when the cited lines -directly supported the stated failure-handling behaviour. The -audit therefore fixes both the repository version and the exact -code location being asserted, making each entry independently -checkable without relying on moving default branches. - -\subsection{Pinned commit SHAs} -\label{app:survey:shas} - -Every permalink in this appendix resolves against the -following pinned commits. Repositories are grouped by survey -stratum; full 40-character SHAs and line-level permalinks are -provided in the supplementary survey document. - -\emph{SWE-bench-family harnesses and agent scaffolds.} -SWE-bench \texttt{f7bbbb2}; SWE-agent \texttt{0f4f3bb}; -mini-swe-agent \texttt{bc85a45}; live-swe-agent -\texttt{8d7dd86}; SWE-bench\_Pro-os \texttt{0c64e26}; -SWE-smith \texttt{9b74ac0}; Agentless \texttt{5ce5888}; -aider \texttt{f09d7065}. - -\emph{RL and agentic-RL training frameworks.} -TRL \texttt{88826fd}; verl \texttt{6eeb571}; OpenRLHF -\texttt{64c1cc4}; rllm \texttt{ea623cc}; slime -\texttt{5b688aa}; ART \texttt{679b236}; Agent-R1 -\texttt{38bdfc1}; RAGEN \texttt{20daedc}; MARTI -\texttt{a2fe2c7}; MATPO \texttt{3c41d62}; RL-Factory -\texttt{d0abc1d}; Trinity-RFT \texttt{9051d63}; ms-swift -\texttt{c4902f3}; verifiers \texttt{e27633b}; open-r1 -\texttt{1416fa0}. - -\emph{General evaluation harnesses and leaderboard infrastructure.} -openai/evals \texttt{8eac7a7}; simple-evals \texttt{ee3b031}; -SciCode \texttt{e3158ea}; lm-evaluation-harness -\texttt{c1c4bea}; lighteval \texttt{10b9104}; inspect\_ai -\texttt{36231d6}; FastChat \texttt{587d5cf}; HELM -\texttt{83bde5c}; ragas \texttt{298b6827}; deepeval -\texttt{f917b5a}; MTEB \texttt{9363ea75}; promptfoo -\texttt{3dc5843}; BIG-bench \texttt{092b196}. - -\emph{Web, GUI, mobile, scientific-agent, and ML-engineering benchmarks.} -WebArena \texttt{dce0468}; VisualWebArena \texttt{89f5af2}; -OSWorld \texttt{c7e54d2}; android\_world \texttt{d9c569f}; -Mind2Web \texttt{33bd95c}; MLE-bench \texttt{2451bcb}; -MLAgentBench \texttt{5d71205}; ScienceAgentBench -\texttt{1cf1375}. - -\emph{Function-calling, multi-agent, and general agent-scaffold repositories.} -gorilla (BFCL) \texttt{6ea5797}; ToolBench \texttt{d56fdd8}; -AgentBench \texttt{d1e4a10}; AgentBoard \texttt{bb7255e}; -tau-bench \texttt{59a200c}; Self-rewarding-reasoning-LLM -\texttt{372bea9}. The out-of-scope observation (OpenHands, -documented as a scope-filter exemplar) is pinned at -\texttt{3b17f27}. - -\subsection{Coverage snapshot} -\label{app:survey:coverage} - -The final sample contains 50 in-scope repositories and two -documented exclusions. It spans SWE-bench-family harnesses, -agent scaffolds, -RLHF trainers, agentic-RL trainers, multi-agent RL, web/GUI -agents, mobile agents, ML-engineering benchmarks, -scientific-agent benchmarks, function-calling benchmarks, -general eval frameworks, LLM-judge infrastructure, autonomous -coding assistants, RAG-eval frameworks, embedding/retrieval -benchmarks, red-team/prompt-eval tooling, and -rejection-sampling SFT pipelines. -The severity distribution is 11 score-3 entries, 31 score-2 -entries, 9 score-1 entries, and 0 score-0 entries. Because -\texttt{simple-evals} is scored separately for SimpleQA and -non-SimpleQA behaviour, these 51 entries correspond to 50 -unique repositories. Seven silent-drop patterns -covering reward coercion, rollout filtering, bypassed saves, -survivor denominators, partial-trajectory drops, LLM-judge -parse$\to$fixed score, and outcome-as-ground-truth SFT -filters. -The audit contains 50 pinned SHAs and approximately 160 -verified source citations. Every code claim in this appendix is a -commit-pinned two-click audit: if any claim fails to hold up -at its permalink, that is a bug in this appendix, not a vague -disagreement about code that has since moved --- the SHAs fix -the audit target. - -% ---------------------------------------------------------------------------- -% Appendix B NEW --- 37-pair variance catalogue. Draft fresh in Session 11. -% Source: SURVEY\_master.md. Structure: full table organised by metric family -% --- task success (22 pairs), cost/tokens (9 pairs), latency/timing (6 pairs). -% Near-misses section for leads not meeting evidence bar. -% ---------------------------------------------------------------------------- - -\section{37-pair Variance Catalogue} -\label{app:variance-catalogue} - -This appendix enumerates the 37 cross-harness variance pairs -sampled in Table~\ref{tab:omnibus}, organised by metric family: -22 task-success pairs (\S\ref{app:vc:task}), 9 cost/token -pairs (\S\ref{app:vc:cost}), and 6 latency/timing pairs -(\S\ref{app:vc:latency}). Four task-success benchmarks are -then examined in \emph{smoking-gun} detail -(\S\ref{app:vc:smoking}) --- same dataset, multiple in-survey -implementations, different numbers on identical workloads. -Training-side evidence beyond the rollout interface -(Dr.\ GRPO, DAPO, TRL's five GRPO variants, verl~\#2165) is -consolidated in \S\ref{app:vc:training}. A de-duplication log -(\S\ref{app:vc:dedup}) records which family each pair is -counted in, and \S\ref{app:vc:nearmisses} catalogues leads that -did not meet the evidence bar. - -\subsection{Evidence hierarchy} -\label{app:vc:hierarchy} - -Every pair is graded against the same four-tier evidence -hierarchy, uniform across the three metric families: - -\begin{enumerate} -\item \textbf{Tier 1 --- strongest.} Same model and workload; -published numbers disagree across two harnesses. (Direct -empirical evidence.) -\item \textbf{Tier 2.} Explicit vendor or framework -documentation acknowledging the convention difference between -harnesses. (Vendor-admission-equivalent.) -\item \textbf{Tier 3.} Code-level convention divergence with -no cross-framework normalization, published under the same -metric name. (Structural divergence visible at source.) -\item \textbf{Tier 4.} Maintainer issue-tracker statement that -the convention differs from another framework. (Maintainer-% -acknowledged divergence.) -\end{enumerate} -Anything weaker is demoted to near-misses -(\S\ref{app:vc:nearmisses}). Every pair below cites either a -numeric delta, a vendor acknowledgement, a code-level -convention divergence, or a maintainer statement. - -\subsection{Task-success pairs (22)} -\label{app:vc:task} - -Same model weights, same benchmark split, different published -accuracy depending on which harness computed the aggregate. - -\begin{table}[h] -\centering -\footnotesize -\setlength{\tabcolsep}{3pt} -\caption{22 task-success variance pairs. $\Delta$ is the gap -between published numbers on an identical workload. ``post-% -mortem'' refers to the HuggingFace Open LLM Leaderboard -post-mortem \citep{beeching2023openllm}.} -\label{tab:vc:task} -\begin{tabular}{@{}l>{\raggedright\arraybackslash}p{0.12\linewidth}>{\raggedright\arraybackslash}p{0.13\linewidth}>{\raggedright\arraybackslash}p{0.14\linewidth}>{\raggedright\arraybackslash}p{0.16\linewidth}>{\raggedright\arraybackslash}p{0.12\linewidth}>{\raggedright\arraybackslash}p{0.16\linewidth}@{}} -\toprule -\textbf{\#} & \textbf{Model} & \textbf{Benchmark} & -\textbf{Harness A} & \textbf{Harness B} & -\textbf{$\Delta$} & \textbf{Source} \\ -\midrule -T1 & LLaMA-65B & MMLU 5-shot & Berkeley orig. & lm-eval-harness & 14.9pp & post-mortem \\ -T2 & LLaMA-30B & MMLU 5-shot & Berkeley orig. & lm-eval-harness & 12.6pp & post-mortem \\ -T3 & Falcon-40B & MMLU 5-shot & Berkeley orig. & lm-eval-harness & 10.5pp & post-mortem \\ -T4 & Llama-3.1-70B-Inst & MMLU-Pro & Meta self (CoT) & OLL v2 / lighteval & $\sim$18.5pp & Meta card vs OLL \\ -T5 & Qwen2.5-72B & MMLU-Pro & Self-report & OLL v2 & $\sim$22pp & Qwen docs vs OLL \\ -T6 & GPT-4o (24-05-13) & SWE-b.\ Verified & SWE-agent & Agentless 1.5 & 15.6pp & swe-b.\ subs.\ S3 \\ -T7 & Claude 3.5 Sonnet & SWE-b.\ Verified & Anthropic Tools & OpenHands CA 2.1 & 4.0pp & SWE-b.\ leaderb. \\ -T8--T13 & Various & SWE-b.\ Lite/Ver. & Various & Various & 3.4--19.6pp & SWE-b.\ leaderb. \\ -T14 & Mistral-7B & MMLU (prompt) & Template A & Template B & up to 24.6pp & Biderman et al.\ \\ -T15 & Mixtral-8x7B & MMLU (prompt) & Template A & Template B & up to 24.6pp & Biderman et al.\ \\ -T16 & Mistral-7B & ARC (prompt) & Template A & Template B & up to 24.6pp & Biderman et al.\ \\ -T17 & Llama-3.1-70B & MATH & Meta (full test) & Leaderboard (500) & label collision & Meta vs leaderb. \\ -T18 & Claude 3.5 Sonnet & MATH & ``MATH'' & ``MATH'' & label overload & multi-source \\ -T19 & Llama-3.1-70B & GPQA & simple-evals & lm-eval-harness & vendor admis.\ & NVIDIA NeMo \\ -T20 & Llama-3.1-70B & GPQA & Self-report & Leaderboard & cross-harness & Meta + leaderb.\ \\ -T21 & Llama-3.1-70B & Various & Meta model card & Meta \texttt{eval\_details.md} & micro vs macro & Meta own docs \\ -T22 & Llama-3.1-70B & Various & \texttt{acc\_char} & full-string match & convention & Meta own docs \\ -\bottomrule -\end{tabular} -\end{table} - -T1 (LLaMA-65B MMLU 14.9pp) is the flagship single pair --- -identical weights, different published score, explicitly -discussed in a HuggingFace post-mortem. T6 (GPT-4o SWE-bench -Verified 15.6pp) is the target of the reconciliation -experiment in Sec.~\ref{sec:validation:results}: -trajectories for both runs are in the public SWE-bench -submissions S3 bucket, making the reconciliation tractable. -T14--T16 (Biderman et al.) are not cross-harness pairs but -within-harness prompt-template pairs, included because the -24.6pp gap is the strongest published evidence that the -instrument-level variance is at least as large as the -signals it purports to measure. - -\subsection{Cost / token pairs (9)} -\label{app:vc:cost} - -Same model, same workload, different published token counts or -dollar costs depending on which tool applies which convention. -Table~\ref{tab:vc:cost} summarises; for numerical examples and -code-level references see the supplementary survey document and -the cost-latency divergence report in supplementary materials. - -\begin{table}[h] -\centering -\footnotesize -\setlength{\tabcolsep}{3pt} -\caption{9 cost / token-accounting variance pairs. Same model, -same workload, different published numbers.} -\label{tab:vc:cost} -\begin{tabular}{@{}l>{\raggedright\arraybackslash}p{0.15\linewidth}>{\raggedright\arraybackslash}p{0.22\linewidth}>{\raggedright\arraybackslash}p{0.22\linewidth}>{\raggedright\arraybackslash}p{0.28\linewidth}@{}} -\toprule -\textbf{\#} & \textbf{Setup} & \textbf{Harness A} & -\textbf{Harness B} & \textbf{$\Delta$} \\ -\midrule -C1 & Anthropic w/ \texttt{cache\_control} & OTel \texttt{input\_tokens} (inclusive) & Anthropic-native (exclusive) & 2.0$\times$ inflation (260{,}421 vs 130{,}213) \\ -C2 & Claude + cache\_control & LiteLLM cost estimator & Anthropic Console billing & +68\% overestimate (\$0.091 vs \$0.054) \\ -C3 & Claude (streaming) + cache-hit & LangChain.js accumulator & single \texttt{message\_delta} & exactly 2$\times$ cache\_read \\ -C4 & SWE-b.\ V.\ retry-firing task & SWE-agent (every attempt) & mini-swe-agent (last attempt) & 30--100\% per-task overstatement \\ -C5 & Anthropic/OpenAI prompt-cache & inspect\_ai (cache-aware, tiered) & 7 peer harnesses (no cache parse) & up to 90\% on cached portion \\ -C6 & Aider leaderboards (same model date) & Aider Edit & Aider Refactor / Polyglot & \$0 $\to$ \$14.41 (same model) \\ -C7 & Same rollout batch, GRPO training & verl \texttt{loss\_agg\_mode=}\allowbreak\texttt{token-mean} & verl \texttt{seq-mean-}\allowbreak\texttt{token-sum} & different gradient magnitudes \\ -C8 & Cross-vendor ``tokens used'' & tiktoken (\texttt{o200k\_base}) & Anthropic BPE & $\sim$10\% systematic \\ -C9 & OpenAI reasoning models (o1/o3) & harness reading \texttt{completion\_}\allowbreak\texttt{tokens} only & harness reading \texttt{..details.}\allowbreak\texttt{reasoning\_tokens} & silent under-report \\ -\bottomrule -\end{tabular} -\end{table} - -\paragraph{Code-level sources.} -{\footnotesize\raggedright -\textbf{C1:} OpenTelemetry PR~\#3163; Langfuse issue~\#12306. -\textbf{C2:} BerriAI/litellm issue~\#9812. -\textbf{C3:} langchainjs issue~\#10249. -\textbf{C4:} \texttt{sweagent/agent/models.py} L744--L838; -\texttt{minisweagent/models/litellm\_model.py} L80--L93. -\textbf{C5:} \texttt{inspect\_ai/model/\_openai.py} L776--L782; -\texttt{inspect\_ai/model/\_model.py} L2085--L2091; -\texttt{inspect\_ai/model/\_providers/anthropic.py} L1137--L1169. -\textbf{C6:} \texttt{edit\_leaderboard.yml}, -\texttt{refactor\_leaderboard.yml}, -\texttt{polyglot\_leaderboard.yml} in \texttt{Aider-AI/aider}. -\textbf{C7:} \texttt{verl/trainer/ppo/core\_algos.py} L1168--L1195. -\textbf{C8:} tiktoken and Anthropic tokenizer documentation; -openai/tiktoken issue~\#474. -\textbf{C9:} OpenAI API reference for reasoning-model -\texttt{usage.completion\_tokens\_details}. -\par} - -C1 is the flagship: OpenTelemetry PR~\#3163 formalises the -Anthropic/OpenAI/Vertex convention disagreement at the -observability-middleware layer, and Langfuse issue \#12306 -exhibits the numerical 260{,}421-vs-130{,}213 gap on a single -request. C6 is the flagship intra-vendor disagreement: the -same organisation's three leaderboards (Edit, Refactor, -Polyglot) publish \$0, \$8.46, and \$14.41 for the same model -on comparable tasks, with no convention statement in any of -the three YAMLs. - -\subsection{Latency / timing pairs (6)} -\label{app:vc:latency} - -Same workload, same hardware, different published wall-clock -times depending on which harness's timer wraps which phase. - -\begin{table}[h] -\centering -\footnotesize -\setlength{\tabcolsep}{3pt} -\caption{6 latency / wall-clock variance pairs. Clock-start -and clock-stop differ across harnesses that log the same -metric name.} -\label{tab:vc:latency} -\begin{tabular}{@{}l>{\raggedright\arraybackslash}p{0.17\linewidth}>{\raggedright\arraybackslash}p{0.22\linewidth}>{\raggedright\arraybackslash}p{0.22\linewidth}>{\raggedright\arraybackslash}p{0.26\linewidth}@{}} -\toprule -\textbf{\#} & \textbf{Setup} & \textbf{Harness A} & -\textbf{Harness B} & \textbf{$\Delta$} \\ -\midrule -L1 & SWE-b.\ V.\ (same task) & Docker backend & Modal backend & systematic Modal inflation \\ -L2 & OSWorld (same task) & \texttt{lib\_run\_single.py} & \texttt{scripts/python/}\allowbreak\texttt{run\_maestro.py} & $\ge$30\,s (hardcoded sleep) + 5--15\,s VM start \\ -L3 & GRPO step timer & TRL \texttt{training\_}\allowbreak\texttt{step} (grad+opt only) & verl \texttt{marked\_timer(}\allowbreak\texttt{"step")} (full epoch) & up to 5$\times$ on rollout-dominated runs \\ -L4 & Qwen2.5-7B GSM8K-GRPO, same HW & TRL & OpenRLHF & 3.13$\times$ (5{,}189\,s vs 1{,}657\,s) \\ -L5 & GRPO throughput & verl \texttt{perf/throughput} (excl.\ padding) & TRL effective-token throughput & different numerators on same HW \\ -L6 & inspect\_ai timers & \texttt{sample\_working\_time} & \texttt{sample\_waiting\_time} & ambiguity declared in-tool \\ -\bottomrule -\end{tabular} -\end{table} - -\paragraph{Code-level sources.} -{\footnotesize\raggedright -\textbf{L1:} \texttt{swebench/harness/docker\_utils.py} -L203--L217 vs -\texttt{swebench/harness/modal\_eval/run\_evaluation\_modal.py} -L307--L319. -\textbf{L2:} \texttt{lib\_run\_single.py} \#L616 vs -\texttt{scripts/python/run\_maestro.py} \#L351. -\textbf{L3:} \texttt{trl/trainer/grpo\_trainer.py} L1111--L1120; -\texttt{verl/trainer/ppo/metric\_utils.py} L313--L346. -\textbf{L4:} arXiv:2501.03262 Table~4; EMNLP-demos 2025. -\textbf{L5:} \texttt{verl/trainer/ppo/metric\_utils.py} -L337--L345. -\textbf{L6:} \texttt{inspect\_ai/\_util/working.py}. -\par} - -L4 is the flagship: published, peer-reviewed, same hardware, -same model, same algorithm --- a 3.13$\times$ wall-clock gap -attributable to framework-level dispatch rather than algorithm -difference. L1 and L2 are in-repository disagreements: both -backends in SWE-bench Verified and both harnesses in OSWorld -ship under the same repo at the same commit, reporting the -same metric name, with no statement that the two are not -comparable. - -\subsection{Smoking-gun benchmarks: same dataset, multiple in-survey implementations} -\label{app:vc:smoking} - -Four benchmarks are implemented in 3--4 surveyed repositories -each. Below, each implementation's grading site and -failure-handling semantics are read off at the pinned SHAs -(\S\ref{app:survey:shas}). For each benchmark, a single -realistic failure mode produces a different headline number in -each implementation --- and none of the implementations emits -a counter disclosing which regime it fell into. - -\paragraph{Smoking-Gun~\#1: SWE-bench (4 implementations).} -\emph{Official SWE-bench} grades via \texttt{bad\_codes} = -\{APPLY\_PATCH\_FAIL, RESET\_FAILED, TESTS\_ERROR, -TESTS\_TIMEOUT\} -(\texttt{swebench/harness/grading.py:L61-L70}; -\texttt{constants/\_\_init\_\_.py:L80-L89}) with empty patches -pre-filtered from submission (\texttt{docs/faq.md:L39}). Container -timeout stays in the denominator as a fail; empty-patch -instances are removed entirely. \emph{PrimeIntellect verifiers} -reads the same \texttt{bad\_codes} set but wraps -\texttt{test\_spec} fetching in a 5$\times$ tenacity retry; any -uncaught exception flows through \texttt{rubric.py:L144-L158} -to \texttt{0.0} and enters the group-mean advantage baseline ---- a container timeout can bias the policy gradient for the -other rollouts in its group rather than merely decrementing -the numerator. \emph{SWE-agent} delegates grading to -\texttt{sb-cli submit} -(\texttt{sweagent/run/hooks/swe\_bench\_evaluate.py:L42-L55}); -the pre-submission \texttt{unlink} at -\texttt{run\_batch.py:L397-L401} removes crashed or -cost-killed instances from \texttt{preds.json} entirely, so -server-side grading sees a smaller denominator instead of a -failed row. \emph{mini-swe-agent} has no local grading; its -\texttt{finally: save()} at -\texttt{benchmarks/swebench.py:L171} is bypassed by -\texttt{KeyboardInterrupt} (closed issue \#329), also -producing an absent-from-submission pattern. On one realistic -mid-test Docker timeout, the four harnesses report: Official -$0/N$, verifiers $1/N$ (possible retry recovery), SWE-agent -$0/(N-1)$, mini-swe-agent $0/(N-1)$. Inspect~AI has \emph{no -SWE-bench bridge} at the pinned SHA. - -\paragraph{Smoking-Gun~\#2: GPQA (3 implementations).} -\emph{simple-evals} at \texttt{gpqa\_eval.py:L59} uses -\texttt{re.search(ANSWER\_PATTERN\_MULTICHOICE, response\_text)} -and \texttt{extracted\_answer = match.group(1) if match else -None}; a regex miss --- which a hedging response triggers --- -is scored 0 and stays in the denominator. \emph{lm-evaluation-% -harness} uses log-likelihood of the gold-choice token over the -various \texttt{gpqa\_*\_zeroshot.yaml} / -\texttt{\_n\_shot.yaml} / \texttt{\_cot\_*.yaml} tasks; the -model does not generate free text in the scored branch, so -hedging is immune, but silent left-truncation at -\texttt{huggingface.py:L1360-L1368} still affects long -contexts. \emph{lighteval} scores via the standard pipeline at -\texttt{tasks/tasks/gpqa.py} with aggregation through -\texttt{info\_loggers.py:L326-L400}; API retry-exhaust or -content-filter yields an empty -\texttt{LitellmModelResponse()} (\texttt{litellm\_model.py:% -L243, L254}), scored as wrong via -\texttt{metrics\_sample.py:L151-L152} (\texttt{if not pred: -return 0}). On a single hedging model output: simple-evals~0, -lm-eval-harness likely correct, lighteval~wrong --- three -different headline accuracies from the same model reasoning. -Inspect~AI has \emph{no GPQA task file} at the pinned SHA. - -\paragraph{Smoking-Gun~\#3: MATH / MATH-500 (3 -implementations).} \emph{simple-evals} at -\texttt{math\_eval.py:L55} calls \texttt{check\_equality(self.% -equality\_checker, row["Answer"], extracted\_answer)}, which -itself calls the LLM equality checker; an API failure raises -(dropping the sample) or is coerced to 0 by the outer handler. -\emph{lighteval} uses majority@n with a symbolic -\texttt{math\_normalizer} over \texttt{tasks/tasks/math\_500.py} -and \texttt{math.py}; normalizer exceptions are caught upstream -and scored wrong at -\texttt{metrics\_sample.py:L151-L152}. \emph{PrimeIntellect -verifiers} routes reward functions through -\texttt{rubric.py:L144-L158}; exceptions become \texttt{0.0} -and enter the group advantage baseline. On a LaTeX edge case -such as \texttt{\textbackslash frac\{1\}\{2\}} versus -\texttt{0.5}: simple-evals's LLM judge may call these equal, -lighteval's normalizer may not, and verifiers's reward -exception enters training as an errored zero --- same -completion, three different outcomes. - -\paragraph{Smoking-Gun~\#4: MMLU (3 implementations).} -\emph{simple-evals} (\texttt{mmlu\_eval.py}) extracts answers -by regex on generated text --- same miss-is-wrong pattern as -GPQA. \emph{lm-evaluation-harness} -(\texttt{lm\_eval/tasks/mmlu}: 12+ variants spanning main, -redux, pro, flan, etc.) uses log-likelihood over A/B/C/D -tokens with \texttt{acc}/\texttt{acc\_norm}; silent prompt -truncation at \texttt{huggingface.py:L1360-L1368} logs the -truncated-prompt sample as a complete result. -\emph{lighteval} uses exact-match on the choice label -(\texttt{tasks/tasks/mmlu.py}), with left-truncation at -\texttt{vllm\_model.py:L374-L397} warning but recording no -\texttt{truncated\_tokens\_count}. On a long-context MMLU-Pro -prompt that overflows a 4k model: simple-evals generates a -regex answer against the truncated prompt the model saw -(right or wrong depending on the content); -lm-eval-harness left-truncates and logs as complete (silently -wrong); lighteval left-truncates, records no truncation -counter, and logs as complete (silently wrong, with no -recoverable audit trail). Three implementations, three -behaviours, no \texttt{n\_prompt\_truncated} counter in any -output. - -\subsection{Training-side evidence beyond the rollout interface} -\label{app:vc:training} - -Operator disagreement extends into the gradient-signal -computation at training time. The paper cites these as -published findings rather than original contributions. - -\textbf{Dr.\ GRPO} \citep{liu2025drgrpo} corrects only the -loss-aggregation convention --- the length-normalization bias -documented in ``all popular open-source PPO implementations'' ---- and reports +7.3 points on AIME 2024 vs SimpleRL-Zero-7B -and +15.7 points on AIME 2024 vs Prime-Zero-7B, with no change -to data, model, or algorithm. \textbf{DAPO} -\citep{yu2025dapo} is a catalogue of framework-level defaults -(zero-std group filter, token-vs-sequence loss aggregation, -decoupled clip ranges) that had to be flipped to match -DeepSeek-R1; the paper reports 50 points on AIME 2024 for -Qwen2.5-32B on the defaults-flipped run. All three defaults -are silently different across TRL, verl, and OpenRLHF. -\textbf{TRL's own library} ships five mutually-incompatible -GRPO variants --- \texttt{grpo}, \texttt{dr\_grpo}, -\texttt{dapo}, \texttt{bnpo}, and -\texttt{importance\_sampling\_level}$\in$\{\texttt{token}, -\texttt{sequence}\} --- in a single config file -(\texttt{trl/trl/trainer/grpo\_config.py:L228-L243, -L668-L677}); two papers reporting ``we trained with GRPO on -task X'' are structurally non-comparable without specifying -which variant. \textbf{verl~\#2165} \citep{verl2165} is a -tokenization-channel divergence on Qwen3-4B GRPO where the -training-side FSDP tokenizer inserts -\texttt{\textbackslash n\textbackslash n} into -assistant turns that the rollout engine (vLLM~/~SGLang) never -saw, shifting the assistant-turn token mask by tens of tokens -across $\sim$40k-token conversations. The finding is reproduced -by six independent users on the thread, upstream-confirmed in -QwenLM/Qwen3~\#1826 and Qwen/Qwen3-1.7B HF discussion~\#9, and -guarded separately by verl's own -\texttt{tokenization\_sanity\_check\_mode} (distinct from the -dtype guard in discussion~\#5984) --- proving that tokenization-% -class mismatch exists as a separate failure mode even when all -upstream dtypes match. - -\textbf{Silent rollout-dropping mechanisms (beyond the 50-repo -audit).} verl~\#1170 (rollouts silently return empty strings, -training proceeds); OpenRLHF~\#1108 (reward curves diverge -between vLLM 0.8.1 and 0.8.3 on the same seed); verl -discussion~\#5984 (tool built specifically to detect silent -per-token log-prob divergence between training FSDP and vLLM -rollouts); ms-swift~\#9096 (gemma-4 rollouts return garbage, -training continues). \textbf{Numerical-layer evidence.} BF16 -backends have been documented to produce divergent log-probs -between training and rollout engines even with identical -weights (``Defeating the Training-Inference Mismatch via -FP16,'' arXiv:2510.26788); the vLLM engineering blog documents -separate backends breaking the on-policy assumption of -policy-gradient methods; THUDM's \texttt{slime} ships a -\texttt{train\_infer\_mismatch\_helper} because divergence is -expected. - -\subsection{De-duplication log} -\label{app:vc:dedup} - -Several findings could plausibly appear in more than one -family. For audit transparency we record where each is counted -and why: - -\begin{itemize} -\item \textbf{Dr.\ GRPO}, \textbf{DAPO}, \textbf{TRL's five -GRPO variants}: counted in \S\ref{app:vc:training} -(training-side); not in \S\ref{app:vc:cost} despite touching -per-step token accounting. Rationale: the load-bearing claim is -about gradient signal, not token counts. -\item \textbf{verl~\#2165} (Qwen3 tokenization-channel -divergence): counted in \S\ref{app:vc:training} (training-% -side); not in \S\ref{app:survey} despite involving silent -per-rollout mismatch. Rationale: Appendix~\ref{app:survey} -audits \emph{failure-handling} patterns; verl~\#2165 is a -\emph{convention-disagreement} finding, structurally closer to -Dr.\ GRPO than to \texttt{try/except/return None}. -\item \textbf{TRL vs OpenRLHF 3.13$\times$ wall-clock} (L4): -counted in \S\ref{app:vc:latency}; not in -\S\ref{app:vc:training} despite the training-pipeline context. -The headline delta is wall-clock, not gradient signal. -\item \textbf{verl \texttt{loss\_agg\_mode} variants} (C7): -counted in \S\ref{app:vc:cost}; not in \S\ref{app:vc:latency} -despite affecting per-step timing. -\item \textbf{verl throughput metric} (L5): counted in -\S\ref{app:vc:latency}; not in \S\ref{app:vc:cost} despite -involving token counting. -\item \textbf{inspect\_ai \texttt{sample\_working\_time} vs -\texttt{sample\_waiting\_time}} (L6): counted in -\S\ref{app:vc:latency}; not in \S\ref{app:survey} despite -inspect\_ai's role as the positive control for -failure-handling. -\end{itemize} -No finding is counted twice across the 37 pairs. - -\subsection{Near misses} -\label{app:vc:nearmisses} - -Leads that did not meet the evidence bar -(\S\ref{app:vc:hierarchy}) but warrant human follow-up. - -\textbf{Cost / tokens.} Anthropic -\texttt{/v1/messages/count\_tokens} versus tiktoken estimate ---- Anthropic publishes a dedicated counting endpoint, -community harnesses routinely substitute tiktoken for speed, -the endpoint returns different numbers on the same input; -documented but no published benchmark pair with materially -different totals attributable to this choice. Anthropic -\texttt{/cost} underreport vs dashboard (anthropics/claude-% -code~\#1063): intra-vendor, not cross-harness. HuggingFace -Open LLM Leaderboard v1$\to$v2 transition (including the -lighteval vs lm-eval-harness switch): full methodology change -with no explicit ``efficiency numbers not comparable across -versions'' statement. - -\textbf{Latency.} HELM \texttt{efficiency.json} publishes -per-model latency/throughput but is frozen at pre-2023 models -with no modern cross-comparison. OpenAI vs Azure OpenAI -latency differences on the same model are documented but with -no published benchmark pair. - -\textbf{Task success.} Anecdotal ``our replication differs -from the original paper'' issue-tracker threads that do not -cite a specific convention difference. - -% ---------------------------------------------------------------------------- -% Appendix C --- Projection Operators to Community Trace Shapes. -% EDIT per Appendix Handling table: cut pseudocode from six subsections -% (terminology-glossary-enforced principle under v5.6), keep Target/Preserved/ -% Dropped paragraphs. EXPAND with $\tau_E$ schema details moved out of Sec.~3.1 body -% during Session 11 appendix work. -% Content initially carried verbatim from main.tex lines 983-1388, then edited. -% ---------------------------------------------------------------------------- - -\section{Projection Operators to Community Trace Shapes} -\label{app:projections} - -This appendix defines each projection -$\pi: \tau_E \to (T_\pi, D_\pi)$ whose structural preservation -properties appear in Table~\ref{tab:preservation}. For each we -give: (i) the target trace format as a formal object; -(ii) pseudocode for the projection function operating on -$\tau_E = (N, E, C, A, M)$; (iii) the drops manifest $D_\pi$ as -a list of typed erasure records; (iv) a characterisation of which -substrate structure the projection preserves. Reference -implementations are in the released codebase; the pseudocode here -is the specification, not the implementation. Each drops manifest -record is a tuple -$(\textit{erasure\_type}, \textit{element\_ref}, -\textit{metric\_class})$ per~\S\ref{app:projections}. - -Figure~\ref{fig:sixproj} gives the visual summary before the -definitions: one SWE-Bench Verified rollout $\tau_E$ rendered -through all six projections, with each projection's drops -manifest overlaid. - -\begin{figure}[h] -\placeholder{0.95}{% - \textbf{Figure C.1: One SWE-Bench Verified trajectory, six - representations.} Centre: substrate state $\tau_E$ for one - rollout, rendered with containment tree (vertical), dependency - DAG (horizontal edges), reasoning-log events (timeline), and - typed mutations (marked). Five surrounding panels: the same - trajectory under each projection below. Drops manifest records - overlaid on each panel, colour-coded by erasure type (concurrency - collapse, cancellation erasure, edge deletion, containment - flattening, reasoning truncation, attribution loss). - $\pi_{\text{step}}$ and $\pi_{\text{per-agent}}$ each show - large shaded regions (structural erasure); - $\pi_{\text{call-tree}}$ serialises concurrent siblings into an - ordering; $\pi_{\text{macro}}$ collapses sub-tree reasoning - into aggregate effects; $\pi_{\text{json-log}}$ matches the - substrate up to schema encoding. -} -\caption{One SWE-Bench Verified trajectory rendered through all -six representations, with drops manifests overlaid.} -\label{fig:sixproj} -\end{figure} - -\paragraph{Preservation, operationally.} We use ``projection'' -informally --- a function that loses information. A behavioural -quantity $\mu: \tau_E \to \mathbb{R}$ is \emph{$\pi$-preserved} -iff there exists a computable $g$ such that -$g(\pi(\tau)) = \mu(\tau)$ for all $\tau$; that is, $\mu$ factors -through $\pi$. Table~\ref{tab:preservation} records three cases -per (projection, quantity) pair: (i)~\textbf{preserved} -(\ding{51}): such a $g$ exists given $T_\pi$'s canonical schema -alone; (ii)~\textbf{erased} (\ding{55}): no such $g$ exists -because $T_\pi$ lacks the relevant channel; (iii)~\textbf{partial} -($\sim$): $g$ exists on a subset of $\tau_E$ but not universally, -admitting a numeric preservation fraction -$\rho_{\pi,\mu} \in (0,1)$ whose computation requires running -$\pi$ against a trajectory corpus and is beyond the scope of this -paper. Each cell is therefore a concrete question (``does $g$ -exist given $T_\pi$'s schema?'') rather than a qualitative -judgement. - -\begin{table}[h] -\centering -\footnotesize -\setlength{\tabcolsep}{4pt} -\caption{Structural preservation matrix. Rows: the six projections -defined below plus the raw substrate $\tau_E$. Columns: the six -community-native behavioural quantities of \S\ref{sec:validation} -(\textbf{Step-ret}: step-indexed return under episode mask, long-horizon -LLM-agentic RL~\citep{chen2025loop, wang2025ragen}; -\textbf{Opt-term}: option termination frequency, -HRL/macro-action~\citep{bacon2017optioncritic}; \textbf{Call-dep}: -call-tree depth distribution, recursive LMs~\citep{zhu2024redel}; -\textbf{Sib-dist}: per-agent sibling embedding distance, fixed-role -MAS~\citep{cemri2025mast}; \textbf{MCTS-ent}: node visit entropy -across frontier, MCTS-based training~\citep{rstarmath2025, -feng2024restmcts}; \textbf{Async-dp}: dispatch-and-wait rate, -async MAS). \ding{51}~=~preserved under $\pi$; -\ding{55}~=~$T_\pi$ has no channel that could carry the elements -contributing to the quantity; $\sim$~=~partially preserved. Cells -follow from the projection pseudocode in -\S\S\ref{app:proj:step}--\ref{app:proj:json-log}.} -\label{tab:preservation} -\begin{tabular}{@{}lcccccc@{}} -\toprule -\textbf{Projection} & \textbf{Step-ret} & \textbf{Opt-term} & \textbf{Call-dep} & \textbf{Sib-dist} & \textbf{MCTS-ent} & \textbf{Async-dp} \\ -\midrule -$\pi_{\text{step}}$ (long-horizon RL) & \ding{51} & \ding{55} & \ding{55} & \ding{55} & \ding{55} & \ding{55} \\ -$\pi_{\text{macro}}$ (hierarchical) & $\sim$ & \ding{51} & \ding{51} & \ding{55} & \ding{55} & $\sim$ \\ -$\pi_{\text{call-tree}}$ (recursive LM) & $\sim$ & $\sim$ & \ding{51} & $\sim$ & \ding{55} & \ding{55} \\ -$\pi_{\text{per-agent}}$ (fixed-role MAS) & $\sim$ & \ding{55} & \ding{55} & \ding{51} & \ding{55} & $\sim$ \\ -$\pi_{\text{mcts}}$ (search-tree) & \ding{55} & \ding{55} & $\sim$ & \ding{55} & \ding{51} & \ding{55} \\ -\midrule -$\pi_{\text{json-log}}$ (production ref.) & \ding{51} & \ding{51} & \ding{51} & \ding{51} & \ding{51} & \ding{51} \\ -$\tau_E$ (Ergon raw) & \ding{51} & \ding{51} & \ding{51} & \ding{51} & \ding{51} & \ding{51} \\ -\bottomrule -\end{tabular} -\end{table} - -Table~\ref{tab:preservation} reads off the projection definitions -below. The pattern is consistent: the five community projections -each preserve their own native quantity on the diagonal and -(mostly) erase the others; $\pi_{\text{step}}$ carries no -structural channel beyond the token-indexed step sequence and so -erases every non-self-community question; -$\pi_{\text{json-log}}$ preserves every quantity by construction -but is not a training target for any of the five communities. -The off-diagonal $\sim$ cells (e.g., -$\pi_{\text{call-tree}} \times$~Sib-dist) indicate cases where -$T_\pi$ carries some but not all of the channel $\mu$ requires: -$\pi_{\text{call-tree}}$ preserves sibling \emph{relations} but -not the reasoning-log content embeddings MAST's role-differentiation -metric computes over. - -A methodological note: Table~\ref{tab:preservation} asks whether -$\mu$ factors through $\pi$, not whether any proxy for $\mu$ -does. A weaker proxy claim --- say, ``fraction of episodes -terminated before the environment terminates'' as a surrogate for -option termination under $\pi_{\text{step}}$ --- may survive -erasure of the canonical quantity. Whether such a proxy counts -as the same claim is a judgement the framework does not -adjudicate; it establishes only that the canonical $\mu$, as -defined over $\tau_E$, does not factor through $\pi$. - -\subsection{$\pi_{\text{step}}$: step-indexed $(o_t, a_t, r_t)$ tuples} -\label{app:proj:step} - -\paragraph{Target format.} A token-indexed flat prompt-response -tensor pair, matching the \texttt{DataProto} -schema~\citep{sheng2024hybridflow} consumed by VERL, OpenRLHF, -and TRL: $(\texttt{prompts}, \texttt{responses}, -\texttt{attention\_mask}, \texttt{response\_mask}, -\texttt{token\_level\_scores}, \texttt{advantages})$, where -\texttt{token\_level\_scores} is a per-token reward tensor of -shape $[\text{batch}, \text{response\_length}]$. Equivalently, a -sequence $T_{\text{step}} = [(o_1, a_1, r_1), (o_2, a_2, r_2), -\ldots]$ where each step corresponds to a token position and -$r_t \in \mathbb{R}$ is its assigned reward. Multi-turn -interactions are handled by concatenating turn tokens into one -flat response per rollout; no per-turn identity is preserved in -the tensor shape. - -\paragraph{Projection pseudocode.} -\begin{lstlisting} -def pi_step(tau_E): - N, E, C, A, M = tau_E - T_step = [] - D = [] # drops manifest - # Walk M in sequence order; emit one step per - # (assistant_text | tool_call -> tool_result) pair - # from the root node's reasoning log only. - root = find_root(N) - events = [e for e in C if e.node_id == root.id] - for e in events_by_sequence(events): - if e.kind == "assistant_text" or e.kind == "tool_call": - a_t = e.payload - o_t = next_observation_after(e, C) - r_t = reward_for(e, A) # from annotation store - T_step.append((o_t, a_t, r_t)) - # Record drops - for n in N: - if n.id != root.id: - D.append(("containment_flattening", n.id, "depth")) - for edge in E: - D.append(("edge_deletion", edge.id, "tree_likeness")) - for m in M: - if m.kind in ("node.added", "node.status_changed") - and m.node_id != root.id: - D.append(("cancellation_erasure" - if "cancel" in m.payload else - "attribution_loss", - m.id, "late_cancel")) - for e in C: - if e.node_id != root.id: - D.append(("reasoning_truncation", e.id, "role_diff")) - return T_step, D -\end{lstlisting} - -\paragraph{Preserved.} The root worker's linear sequence of -assistant text and tool interactions with its own environment. -\paragraph{Dropped.} Everything else: the containment tree beyond -the root, all dependency edges, all cross-worker causal structure, -all cancellation events as typed policy actions (they appear, if -at all, only as the absence of subsequent children), per-worker -identity beyond the root, and the reasoning logs of all -non-root workers. - -\subsection{$\pi_{\text{per-agent}}$: per-worker streams with partner annotations} -\label{app:proj:per-agent} - -\paragraph{Target format.} A mapping from worker identifier to a -per-worker stream: -$T_{\text{per-agent}} = \{ w_i \mapsto \sigma_i \}_{i=1}^{k}$, -where each $\sigma_i$ is a per-worker stream of observations and -actions for worker $w_i$ under a topology declared before the -rollout, optionally annotated with role tags or partner-type -beliefs. This shape covers fixed-role multi-agent LLM systems, -MARL with a centralised critic, proposer/critic debate setups, -and --- as a special case of coordination under partner -uncertainty --- ad hoc teamwork~\citep{mirsky2022aht,wang2024naht}. -Cross-stream references are limited to dependency edges between -streams, which the format does not natively carry. - -\paragraph{Projection pseudocode.} -\begin{lstlisting} -def pi_per_agent(tau_E): - N, E, C, A, M = tau_E - workers = group_by(N, key=lambda n: n.assigned_worker_key) - T_per_agent = {} - D = [] - for w, nodes in workers.items(): - stream = [] - events = [e for n in nodes for e in C if e.node_id == n.id] - for e in events_by_sequence(events): - if e.kind in ("assistant_text", "tool_call", - "tool_result"): - stream.append(event_to_stream_record(e)) - T_per_agent[w] = stream - # Drops - for n in N: - if n.parent_node_id: - D.append(("containment_flattening", n.id, "depth")) - for edge in E: - src_w = worker_of(edge.src, N) - dst_w = worker_of(edge.dst, N) - if src_w != dst_w: - D.append(("edge_deletion", edge.id, "tree_likeness")) - for m in M: - if m.kind == "node.status_changed" - and m.payload.status == "cancelled": - D.append(("cancellation_erasure", m.id, "late_cancel")) - return T_per_agent, D -\end{lstlisting} - -\paragraph{Preserved.} Per-agent trajectory streams indexed by -agent identity, matching the shape MARTI~\citep{marti2025} -distributes to individual policy trainers. Partial concurrency -information: a consumer comparing wall-clock timestamps across -streams can detect overlapping activity. Partial role -differentiation: embedding analysis across the per-agent streams -remains possible, though the stream-identity boundary is now a -projection artefact rather than a containment-tree fact. - -\paragraph{Dropped.} The format is agent-major rather than -work-major: task-level structure --- which subtask is shared -across which agents, which agents are blocked on each other's -dispatches, which branches were cancelled as typed events --- is -carried by the workflow class in systems like MARTI (Multi-Agent -Debate, Mixture-of-Agents, Chain-of-Agents) and is not -recoverable from the per-agent streams alone. Specifically: -containment depth (the parent-child relation flattens to stream -identity); cross-worker dependency edges; cancellation as a -typed action (worker terminations appear only as stream -endings); async dispatch-and-wait (the dispatch is in one -stream, the result in another, with no expressible causal link). - -\subsection{$\pi_{\text{call-tree}}$: nested sub-LM call tree} -\label{app:proj:call-tree} - -\paragraph{Target format.} A tree $T_{\text{call-tree}}$ whose -nodes are sub-LM invocations and whose edges represent -parent-calls-child. Each node carries its prompt, its response, -and its list of (serialised) child invocations. Concurrent siblings -are linearised into a canonical child ordering, typically by -start-time. - -\paragraph{Projection pseudocode.} -\begin{lstlisting} -def pi_call_tree(tau_E): - N, E, C, A, M = tau_E - def build(node): - children = [n for n in N - if n.parent_node_id == node.id] - # Linearise by start time; this is the concurrency erasure - children.sort(key=lambda n: n.created_at) - return { - "prompt": assembled_prompt(node, C), - "response": assembled_response(node, C), - "children": [build(c) for c in children], - } - root = find_root(N) - T_call_tree = build(root) - D = [] - for edge in E: - D.append(("edge_deletion", edge.id, "tree_likeness")) - for n in N: - sibs = siblings(n, N) - concurrent_sibs = [s for s in sibs - if overlaps_in_time(s, n, M)] - for s in concurrent_sibs: - D.append(("concurrency_collapse", (n.id, s.id), - "width")) - for m in M: - if m.kind == "node.status_changed" - and m.payload.status == "cancelled": - D.append(("cancellation_erasure", m.id, "late_cancel")) - return T_call_tree, D -\end{lstlisting} - -\paragraph{Preserved.} Containment depth (the tree structure is -the format). Per-node reasoning. Parent-child attribution. - -\paragraph{Dropped.} Concurrency (siblings are serialised into a -canonical ordering, reducing measured width to one). Dependency -edges (the format has a tree, not a DAG). Cancellation as a typed -action (appears only as truncated child subtrees). Async -dispatch-and-wait (result is a return value, not an event with a -sequence stamp distinct from its dispatch). - -\subsection{$\pi_{\text{macro}}$: hierarchical macro-action decomposition} -\label{app:proj:macro} - -\paragraph{Target format.} An option-tagged state-action -trajectory: each primitive step $(s_t, a_t)$ carries an -additional label $\omega_t$ identifying the currently-active -option, plus a termination flag $\beta_t \in \{0, 1\}$ marking -when the option ended and the meta-policy selected a new -one~\citep{bacon2017optioncritic}. The training signal is the -termination gradient plus the intra-option policy gradient; by -construction the framework is single-active-option, so at each -step exactly one $\omega_t$ is active. - -\paragraph{Projection pseudocode.} -\begin{lstlisting} -def pi_macro(tau_E): - N, E, C, A, M = tau_E - root = find_root(N) - T_macro = [] - for child in direct_children_in_time_order(root, N): - macro = { - "s_start": state_at_start(child, M), - "s_end": state_at_end(child, M), - "t_start": child.created_at, - "t_end": child.completed_at, - "effect": aggregate_subtree(child, N, A), - } - T_macro.append(macro) - D = [] - for n in N: - if n.parent_node_id and n.parent_node_id != root.id: - D.append(("reasoning_truncation", n.id, "role_diff")) - for edge in E: - D.append(("edge_deletion", edge.id, "tree_likeness")) - for m in M: - if m.kind == "node.status_changed" - and m.payload.status == "cancelled": - subtree_descendant = is_descendant(m.node_id, root, N) - if subtree_descendant: - D.append(("cancellation_erasure", m.id, - "late_cancel")) - return T_macro, D -\end{lstlisting} - -\paragraph{Preserved.} Intra-option state-action transitions -(each primitive step $(s_t, a_t)$ is retained with its option -label $\omega_t$). Option boundaries, marked by the termination -flag $\beta_t$. Temporal extension of each option. Aggregate -effect and wall-clock timestamps per option. - -\paragraph{Dropped.} Concurrent option execution: the framework -is single-active-option by construction~\citep{bacon2017optioncritic}, -so any concurrency in $\tau_E$ is serialised. Cross-option -dependencies: the options framework has no concept of one -option's output being an input to another. Cancellation as a -typed action: termination is a learned probability $\beta_\omega(s)$ -rather than an event the agent chose, so there is no record of -\emph{why} a subtree was abandoned. Sub-worker identity within -an option's subtree. - -\subsection{$\pi_{\text{mcts}}$: search tree with visit and value statistics} -\label{app:proj:mcts} - -\paragraph{Target format.} A search tree -$T_{\text{mcts}} = (V, E_{\text{tree}}, B)$ where each vertex -$v \in V$ carries a tuple $(s_v, a_v, N_v, Q_v)$ recording the -state, the action taken from the parent, the visit count, and -the backed-up value estimate; $E_{\text{tree}}$ is the -parent-child relation over $V$; and $B$ is the set of backup -edges $(r, v)$ recording which rollout $r$ contributed its -terminal return to the value estimate at $v$. This is the format -produced by rStar-Math~\citep{rstarmath2025} and -ReST-MCTS*~\citep{restmcts2024}, consumed by AlphaZero-style -training with PUCT visit distributions as policy targets and -backed-up returns as value targets. - -\paragraph{Projection pseudocode.} -\begin{lstlisting} -def pi_mcts(tau_E): - N, E, C, A, M = tau_E - # Identify nodes whose containment subtree constitutes - # an MCTS rollout (by annotation tag or worker key). - rollouts = [n for n in N if is_mcts_rollout(n, A)] - # Build search-tree vertices by aggregating across rollouts - # that share a state prefix. - V = {} # keyed by (s, a) pair - backup_edges = [] - for r in rollouts: - for step_node in traverse_rollout(r, N): - s = state_at(step_node, M, A) - a = action_taken(step_node, C) - key = (s, a) - if key not in V: - V[key] = {"s": s, "a": a, "N_visits": 0, - "Q_value": 0.0} - V[key]["N_visits"] += 1 - # Back up terminal return from this rollout - G = terminal_return(r, A) - V[key]["Q_value"] = ( - (V[key]["Q_value"] * (V[key]["N_visits"] - 1) + G) - / V[key]["N_visits"] - ) - backup_edges.append((r.id, key)) - # Parent-child edges in the search tree - E_tree = build_tree_edges(V, rollouts) - T_mcts = (V, E_tree, backup_edges) - # Drops manifest - D = [] - for n in N: - if not is_mcts_rollout_member(n, A): - # Any delegation structure outside the search recipe - D.append(("attribution_loss", n.id, "depth")) - for edge in E: - if not is_tree_edge_in(edge, E_tree): - D.append(("edge_deletion", edge.id, "tree_likeness")) - for m in M: - if m.kind == "node.status_changed" - and m.payload.status == "cancelled": - D.append(("cancellation_erasure", m.id, "late_cancel")) - for e in C: - if e.kind in ("assistant_text", "thinking") - and not is_part_of_rollout_state(e, rollouts): - D.append(("reasoning_truncation", e.id, "role_diff")) - return T_mcts, D -\end{lstlisting} - -\paragraph{Preserved.} Containment depth along the search-tree -spine. Per-node visit counts and value backups (the signal the -MCTS training step consumes). The rollout-to-node backup edges -that explain which trajectory's terminal return updated which -vertex value. - -\paragraph{Dropped.} Concurrency beyond fixed-width -$(s, a)$-keyed vertices (sibling rollouts that explore the same -$(s, a)$ merge into a single vertex). Delegation structure -outside the search recipe (any node not part of an MCTS rollout, -e.g., concurrent tool calls not on the search tree, is dropped). -Cancellation as a typed event (a cancelled rollout contributes -no backup edge but leaves no explicit cancellation record). -Cross-rollout dependency edges (the format has a tree by -construction). Per-node reasoning text beyond what defines the -state. - -\subsection{$\pi_{\text{json-log}}$: production-orchestration JSON event log (reference projection)} -\label{app:proj:json-log} - -\paragraph{Target format.} An ordered sequence of JSON records -$T_{\text{json-log}} = [r_1, r_2, \ldots]$ where each $r_i$ is a -record with fields -$\{\textit{sequence}, \textit{timestamp}, \textit{kind}, -\textit{node\_ref}, \textit{parent\_ref}, \textit{payload}\}$. -The record set is a near-isomorphism of the mutation log $M$ -extended with assistant text events from $C$. - -\paragraph{Projection pseudocode.} -\begin{lstlisting} -def pi_json_log(tau_E): - N, E, C, A, M = tau_E - records = [] - combined = list(M) + [e for e in C - if e.kind in ("assistant_text", - "tool_call", - "tool_result", - "thinking")] - for item in sorted(combined, key=lambda x: x.sequence): - records.append({ - "sequence": item.sequence, - "timestamp": item.timestamp, - "kind": item.kind, - "node_ref": getattr(item, "node_id", None), - "parent_ref": parent_ref_of(item, N, E), - "payload": item.payload, - }) - D = [] - # Drops manifest is empty of structural erasures; - # only schema-serialisation losses apply (e.g., binary blobs - # in annotations truncated to base64, if policy dictates). - return records, D -\end{lstlisting} - -\paragraph{Preserved.} All structural mutations and all typed -reasoning events, in sequence order, with full parent references -and timestamps. The format is a near-isomorphism of $(M, C)$. - -\paragraph{Dropped.} No structural erasure. The limitation of -$\pi_{\text{json-log}}$ relative to $\tau_E$ is not lost -expressiveness but the absence of (i) a formal projection interface -to other community formats for cross-community analysis, and -(ii) the drops manifest abstraction itself, so that downstream -consumers of a JSON event log that re-encode it into their own -format do so without explicit enumeration of what is lost. Ergon -supplies both. - -\subsection{Adding a further projection} -\label{app:proj:extension} - -A new community trace format is added by writing one projection -function $\pi_{\text{new}}: \tau_E \to (T_{\text{new}}, D_{\text{new}})$ -in the same pattern as above. The substrate $\tau_E$ does not -change; the drops manifest schema does not change; the structural -preservation matrix gains one row. Declarative -planning~\citep{ada2023llmp}, embodied agent traces, and -world-model-based planning are candidate further targets, which -we leave to future work. - - -% ---------------------------------------------------------------------------- -% Appendix D --- Ergon System Details. MINOR EDIT per Appendix Handling table: -% "gradient-ready" -> "RL-trainer-compatible". Otherwise carry verbatim. -% Content initially carried from main.tex lines 1406-1448, then edited. -% ---------------------------------------------------------------------------- - -\section{Ergon System Details} -\label{app:system} - -\subsection{Rollout-card format specification} -\label{app:system:format-spec} - -The rollout-card format of \S\ref{sec:system:format} is a -medium-independent bundle: any backend preserving the row -semantics below can emit and consume valid cards. Ergon's -Postgres schema (\S\ref{app:system:postgres}) is one such -backend; a zip archive written by -\texttt{ergon export-rollout-card} is another; a HuggingFace -dataset shard is a third. The dashboard of -Figure~\ref{fig:dashboard} is a reference \emph{reader} that -loads any of these. - -\paragraph{Bundle layout.} A card is a logical directory of -seven artefacts: \texttt{manifest.json} (run-level metadata, -format version, content hashes); five append-only JSON-lines -streams (\texttt{events.jsonl}, \texttt{nodes.jsonl}, -\texttt{edges.jsonl}, \texttt{annotations.jsonl}, -\texttt{mutations.jsonl}); and an optional \texttt{blobs/} -directory holding content-addressed overflow for payloads above -the inline size cap (default 64\,KB). The directory may be -distributed as-is, packed into a zip or tarball, written to an -object-storage prefix, or materialised as a HuggingFace dataset -repository with the streams as split files. Nothing in the -format assumes Postgres, Python, or any particular runtime. - -\paragraph{Row schemas.} Each stream is a newline-delimited JSON -file; each row has the columns in Table~\ref{tab:rowschemas}. -Column types are JSON primitives (string, integer, object, -null); \texttt{payload} and -\texttt{old\_value}/\texttt{new\_value} columns are arbitrary -JSON objects whose shape depends on a row-level discriminator -(\texttt{event\_type} for events, \texttt{mutation\_type} for -mutations). The discriminator-specific payload shapes for the -Ergon reference implementation are documented in -\S\ref{app:system:postgres} (events) and -Table~\ref{tab:mutations} (mutations). - -\begin{table}[h] -\centering -\small -\caption{Rollout-card JSONL row schemas. Each stream carries a -monotonic \texttt{sequence}; its scope (per-run or -per-\texttt{task\_execution\_id}) is noted. Discriminator -columns select the \texttt{payload} shape.} -\label{tab:rowschemas} -\footnotesize -\setlength{\tabcolsep}{3pt} -\begin{tabular}{@{}l>{\raggedright\arraybackslash}p{0.72\linewidth}@{}} -\toprule -\textbf{Stream} & \textbf{Row schema} \\ -\midrule -\texttt{events.jsonl} & \texttt{(event\_id, task\_execution\_id, worker\_binding\_key, sequence, event\_type, turn\_id, payload, started\_at, completed\_at, policy\_version)}; \texttt{sequence} monotonic per \texttt{task\_execution\_id}. \\ -\texttt{nodes.jsonl} & \texttt{(node\_id, parent\_id, instance\_key, task\_key, status, assigned\_worker\_key, level, created\_at, updated\_at)}. \\ -\texttt{edges.jsonl} & \texttt{(source\_node\_id, target\_node\_id, status, created\_at, updated\_at)}; \texttt{status} $\in$ \{\texttt{pending}, \texttt{satisfied}, \texttt{invalidated}\}. \\ -\texttt{annotations.jsonl} & \texttt{(target\_type, target\_id, namespace, sequence, payload, created\_at)}; latest \texttt{sequence} within \texttt{(target, namespace)} is current; prior rows retained. \\ -\texttt{mutations.jsonl} & \texttt{(sequence, mutation\_type, target\_type, target\_id, actor, old\_value, new\_value, reason, created\_at)}; \texttt{sequence} monotonic per run. \\ -\bottomrule -\end{tabular} -\end{table} - -\paragraph{Semantics-preserving invariants.} Any backend -emitting a card must honour four invariants regardless of how -rows are stored: (i) \texttt{mutations.jsonl} is strictly -append-only --- reversals appear as new mutations, deletions as -tombstones (\S\ref{app:system:mutations}); (ii) -\texttt{events.jsonl} is append-only per -\texttt{task\_execution\_id}; (iii) \texttt{annotations.jsonl} -is namespace-keyed, with the latest \texttt{sequence} within a -\texttt{(target, namespace)} pair as the current value and -prior rows retained for replay; (iv) the DAG implied by -\texttt{edges.jsonl} is acyclic at every point in the run's -lifetime (Ergon enforces this at write time via the -\texttt{edge.added} invariant; \S\ref{app:system:mutations}). -Cards emitted from Ergon's Postgres backend satisfy these by -construction; a third-party backend writing cards directly -must enforce them itself. - -\paragraph{Extensibility.} \texttt{manifest.json} carries a -format version (\texttt{schema: "ergon.tau\_e/0.4"}); consumers -check the major version and tolerate minor-version additions. -Additional columns on existing streams are permitted and must -be ignored by consumers that do not recognise them. The public -extension point for new metadata is -\texttt{annotations.jsonl}'s \texttt{namespace} field: a -consumer claims a namespace, writes domain-specific payloads -under it, and the format guarantees that round-tripping a card -through a reader that does not understand the namespace -preserves those rows intact. One namespace, -\texttt{ergon.task}, is reserved for task payloads; all others -are available to experiments, projections, or external -instrumentation. - -\paragraph{Reference implementations.} The library ships three -artefacts. A Pydantic model set plus JSON-schema export for -the row types above -(\texttt{ergon\_core/core/persistence/rollout\_card/models.py}); -a zip-archive exporter -(\texttt{ergon export-rollout-card }) that emits the -bundle from any completed Postgres run; and a validator -(\texttt{ergon validate-rollout-card }) that checks -bundle-layout and invariant conformance against any candidate -card. The dashboard frontend consumes cards through the same -Pydantic models, and therefore renders any conformant bundle ---- not just Ergon-emitted ones. - -\subsection{Postgres reference backend} -\label{app:system:postgres} - -Ergon's reference backend for the rollout-card format of -\S\ref{app:system:format-spec} is a Postgres schema of ten -SQLModel tables: one root (\texttt{RunRecord}) and nine per-run -subtables organised into three layers. Internally, Ergon models -a run as a tuple $\tau_E = (N, E, C, A, M)$ --- a containment -tree of nodes $N$, a dependency DAG of edges $E$, a typed -reasoning-and-action log $C$, a namespace-keyed annotation -store $A$, and a mutation log $M$. The graph layer concretises -$(N, E, A, M)$ as four tables; the execution layer concretises -$C$ and wraps it in a retry-aware per-attempt wrapper; the communication layer carries inter-agent messages -for multi-agent runs. All nine subtables foreign-key to -\texttt{runs.id}. Figure~\ref{fig:schema} gives the full -topology; schema definitions live in -\texttt{ergon\_core/core/persistence/}\allowbreak\texttt{\{graph,context,telemetry\}/models.py}. - -\begin{figure}[t] -\centering -\makebox[\linewidth][c]{\includegraphics[width=1.15\linewidth]{ergon_schema.png}} -\caption{Ergon's ten-table trajectory schema. All per-run -tables foreign-key to \texttt{runs.id}; the formal-model tags -\texttt{[N]}, \texttt{[E]}, \texttt{[C]}, \texttt{[A]}, -\texttt{[M]} identify the five tables that back each component -of $\tau_E$. Dashed borders mark append-only write-ahead-log -tables (INSERT only); solid borders mark mutable tables (INSERT -+ UPDATE). The single cross-group foreign key -\texttt{node\_id}: Execution~$\to$~Node is the join that lets -reasoning events and generation turns recover their position -in the task DAG.} -\label{fig:schema} -\end{figure} - -\paragraph{Graph layer.} \texttt{RunGraphNode} -(\texttt{persistence/graph/models.py:L44-L89}) stores the -containment tree directly --- each node carries its own -\texttt{parent\_node\_id} and integer \texttt{level}, so the -full hierarchy is one indexed SELECT rather than a recursive -CTE. \texttt{RunGraphEdge} (\texttt{:L96-L116}) stores data -dependencies with status $\in\{$\texttt{pending}, -\texttt{satisfied}, \texttt{invalidated}$\}$. -\texttt{RunGraphAnnotation} (\texttt{:L123-L165}) and -\texttt{RunGraphMutation} (\texttt{:L172-L186}) are both -append-only: each carries a per-run monotonic -\texttt{sequence}, and any state at any past point in a run is -reconstructed by replaying mutations up to that sequence -against an empty graph. Annotations additionally carry a -\texttt{namespace} so multiple subsystems (experiment config, -trainer hints, dashboard state) can version their metadata -independently on the same target. - -\paragraph{Execution layer.} \texttt{RunTaskExecution} -(\texttt{persistence/telemetry/models.py:L96-L157}) wraps one -attempt at a node: if a worker retries, each attempt gets its -own row with an incremented \texttt{attempt\_number}, so the -context events and generation turns of failed attempts are -preserved alongside the successful one. -\texttt{RunContextEvent} (\texttt{persistence/context/models.py:L25-L49}) -is the typed reasoning log --- the substrate backing $C$. It -is append-only, with a \texttt{sequence} unique per -\texttt{task\_execution\_id}, and a discriminated-union -\texttt{payload} whose shape depends on \texttt{event\_type}: -\begin{description}[leftmargin=0pt,itemindent=0pt,labelindent=0pt,labelsep=0.4em,itemsep=1pt,topsep=2pt,parsep=0pt,font=\normalfont\bfseries] -\sloppy -\item[system\_prompt, user\_message:] plain \texttt{text} - (user messages additionally carry \texttt{from\_worker\_key} - to attribute inter-worker sends). -\item[assistant\_text, thinking:] \texttt{text}, - \texttt{turn\_id}, and optional \texttt{turn\_token\_ids} / - \texttt{turn\_logprobs} (populated for vLLM, absent for - cloud APIs that do not expose token-level information). -\item[tool\_call:] \texttt{tool\_call\_id}, \texttt{tool\_name}, - \texttt{args}, plus the same token/logprob fields. -\item[tool\_result:] \texttt{tool\_call\_id}, - \texttt{tool\_name}, \texttt{result}, \texttt{is\_error}. -\end{description} -\texttt{RunGenerationTurn} -(\texttt{persistence/telemetry/models.py:L383-L464}) is the -per-model-call convenience extraction: one row per call, with -the raw response object, an extracted \texttt{response\_text}, -\texttt{token\_ids\_json}, \texttt{logprobs\_json}, and -\texttt{tool\_calls\_json}. It is redundant with -\texttt{RunContextEvent} --- the event log is the substrate, -the turn table is a reader-friendly index --- but carrying -both avoids rehydrating token arrays from event payloads on -every training step. - -\paragraph{Communication layer.} \texttt{Thread} -(\texttt{persistence/telemetry/models.py:L343-L353}) and -\texttt{ThreadMessage} (\texttt{:L360-L376}) are used only by -multi-agent runs. A thread is a durable channel between two -named agents within a run; each \texttt{ThreadMessage} carries -the sending agent's current \texttt{task\_execution\_id}, so a -replay can recover which reasoning step authored which -message. Single-agent runs leave both tables empty. - -\paragraph{Engine.} The production deployment is PostgreSQL -15; SQLite is used only for test fixtures, which forces JSON -payload columns to use the broadly portable \texttt{JSON} type -rather than \texttt{JSONB}. The consequence is that projection -code (\S\ref{app:projections}) must read JSON payloads -client-side rather than push them through Postgres path -operators, but this is cheap because projections run at export -time, not on the rollout hot path. Migrations are managed by -Alembic at \texttt{ergon\_core/migrations/}. - -\subsection{Mutation Kinds} -\label{app:system:mutations} - -Every change to the run graph flows through a single -dispatcher, \texttt{GraphRepository.\_log\_mutation} -(\texttt{ergon\_core/core/runtime/services/}\allowbreak\texttt{graph\_repository.py:L848-L890}), -which allocates the next per-run \texttt{sequence} (line -L848-L856: \texttt{SELECT MAX(sequence)+1} within the current -run), writes one \texttt{RunGraphMutation} row with -\texttt{old\_value}/\texttt{new\_value} snapshots, and fires -registered listeners asynchronously. Mutations are strictly -append-only: reversals produce new mutations, not edits to -prior ones; deletions are tombstones (see -\texttt{annotation.deleted} below). The system defines nine -mutation kinds (literal declaration at -\texttt{persistence/graph/models.py:L24-L34}), summarised in -Table~\ref{tab:mutations}. - -\begin{table}[h] -\centering -\small -\caption{The nine mutation kinds. \emph{Payload} lists the -domain-specific fields beyond the common -\texttt{(sequence, target\_type, target\_id, actor, -old\_value, new\_value)}. \emph{Invariants} are checked in the -dispatch path at -\texttt{runtime/services/graph\_repository.py}; when no entry -appears, the repository performs no domain validation and -defers enforcement to the experiment layer (per the repository -contract at \texttt{graph\_repository.py:L7-L9}).} -\label{tab:mutations} -\footnotesize -\setlength{\tabcolsep}{3pt} -\begin{tabular}{@{}l>{\raggedright\arraybackslash}p{0.22\linewidth}>{\raggedright\arraybackslash}p{0.42\linewidth}l@{}} -\toprule -\textbf{Kind} & \textbf{Payload} & \textbf{Invariants} & \textbf{File:L-L} \\ -\midrule -\texttt{node.added} & \texttt{task\_slug}, \texttt{instance\_key}, \texttt{description}, \texttt{status}, \texttt{assigned\_worker\_slug} & None. & \texttt{:L302-L312} \\ -\texttt{node.removed} & (same as above) & Cascades \texttt{edge.removed} for all incident edges first, then marks node terminal (node rows are not deleted). & \texttt{:L314-L358} \\ -\texttt{node.status\_changed} & \texttt{status} & If \texttt{only\_if\_not\_terminal=True}, skip if already in \{\texttt{COMPLETED}, \texttt{FAILED}, \texttt{CANCELLED}\}. & \texttt{:L360-L400} \\ -\texttt{node.field\_changed} & \texttt{field}, \texttt{value} & \texttt{field}~$\in$~\{\texttt{description}, \texttt{assigned\_worker\_slug}\} (whitelist at \texttt{:L62}); else \texttt{ValueError}. & \texttt{:L402-L434} \\ -\texttt{edge.added} & \texttt{source\_node\_id}, \texttt{target\_node\_id}, \texttt{status} & Both endpoints must exist (\texttt{DanglingEdgeError}); new edge must not create a cycle (DFS at \texttt{:L892-L915}). & \texttt{:L438-L474} \\ -\texttt{edge.removed} & (same as above) & None; edge row marked terminal (not deleted). & \texttt{:L476-L502} \\ -\texttt{edge.status\_changed} & \texttt{status} & None at repo layer; lifecycle \texttt{pending}~$\to$~\texttt{satisfied} / \texttt{invalidated} is experiment-layer policy. & \texttt{:L504-L531} \\ -\texttt{annotation.set} & \texttt{namespace}, \texttt{payload} & None; each call inserts a new annotation row (no upsert). Latest \texttt{sequence} within namespace is the current value. & \texttt{:L535-L573} \\ -\texttt{annotation.deleted} & \texttt{namespace}, \texttt{payload} & Soft delete: inserts a tombstone row with empty payload so the append-only log retains full history. & \texttt{:L647-L685} \\ -\bottomrule -\end{tabular} -\end{table} - -Three invariants are worth calling out because they resolve -otherwise-tricky concurrent-write conditions without -distributed coordination: - -\paragraph{Acyclicity on \texttt{edge.added} -(\texttt{:L892-L915}).} Each \texttt{edge.added} runs a DFS -from \texttt{target\_node\_id} following outgoing edges; if -the walk reaches \texttt{source\_node\_id}, the insertion is -rejected with \texttt{CycleError}. Enforcing this at the -repository rather than the experiment layer means every run -graph is a DAG by construction, which is what downstream -projections ($\pi_{\text{call-tree}}$, -$\pi_{\text{per-agent}}$) rely on for their topological walks. - -\paragraph{Terminal-write guard on -\texttt{node.status\_changed} (\texttt{:L381-L382}).} When -called with \texttt{only\_if\_not\_terminal=True}, the mutation -is skipped if the node already holds a terminal status -(\texttt{COMPLETED}, \texttt{FAILED}, or \texttt{CANCELLED} -per \texttt{status\_conventions.py:L23}). This single check -resolves cascade-cancellation races: concurrent paths that -both attempt to write a terminal status converge on -first-writer-wins without requiring a distributed lock. - -\paragraph{Tombstone semantics on -\texttt{annotation.deleted} (\texttt{:L662-L672}).} A -``deletion'' inserts a new annotation row with an empty -payload rather than removing prior rows. This preserves the -ability to replay the full annotation timeline --- the -diagnostic dashboards and the counterfactual-replay tooling -both depend on being able to reconstruct every historical -payload at any sequence, including the ones that were later -cleared. - -\paragraph{Edge-status lifecycle.} Conventionally -\texttt{pending}~$\to$~\texttt{satisfied} on dependency -resolution and \texttt{pending}~$\to$~\texttt{invalidated} on -upstream cancellation; backward transitions are not -prevented at the repository, but the experiment layer that -schedules workers treats \texttt{invalidated} as terminal. -The string values are constants in -\texttt{status\_conventions.py:L30-L32} and are not enforced -by a DB-level check constraint --- custom experiment graphs -are free to add their own edge statuses, at the cost of being -invisible to default dashboards. - -\subsection{Trainer Adapters} -\label{app:verl} - -Ergon decouples rollout execution from trainer execution by -serving a small HTTP surface that all three supported trainers -(TRL, VERL, OpenRLHF) consume. The server is a FastAPI -application at \texttt{ergon\_core/core/api/rollouts.py:L24-L89}; -trainer-side adapters are -$<100$-line shims in -\texttt{ergon\_infra/ergon\_infra/adapters/}. The trainers pull; -the server never pushes. - -\paragraph{Endpoints.} Four routes, all under -\texttt{/rollouts}. \texttt{POST /submit} accepts a -\texttt{SubmitRequest} (\texttt{core/rl/rollout\_types.py:L21-L35}) -carrying \texttt{definition\_id}, \texttt{num\_episodes}, -\texttt{policy\_version}, and an optional -\texttt{model\_target\_override}; it returns -\texttt{SubmitResponse} with a \texttt{batch\_id}, a list of -\texttt{run\_ids}, and the initial \texttt{BatchStatus} (status -202). \texttt{GET /\{batch\_id\}} returns a \texttt{PollResponse} -(\texttt{:L61-L69}) with \texttt{status}, \texttt{completed}, -\texttt{total}, the ordered list of completed -\texttt{Trajectory}~objects, and a list of -\texttt{EpisodeFailure}~records for any runs that crashed. -\texttt{DELETE /\{batch\_id\}} cancels an in-flight batch. -\texttt{POST /sync-weights} triggers an optional vLLM reload -for full-weight RFT scenarios (\texttt{:L67-L88}). Batch and -per-episode state are persisted in Postgres -(\texttt{RolloutBatch}, \texttt{RolloutBatchRun}), so trainers -can resume polling after either a trainer-side or server-side -restart. - -\paragraph{Intermediate representation.} The -\texttt{Trajectory} object (\texttt{rollout\_types.py:L38-L51}) -is the one wire-format contract the three adapters share. It -is a flat tuple: -\begin{center} -\texttt{(run\_id, agent\_id, prompt\_ids, completion\_ids, -logprobs, env\_mask, reward, num\_turns)}. -\end{center} -It is constructed at poll time by -\texttt{extract\_agent\_trajectories} in -\texttt{core/rl/extraction.py:L49-L117}. The extraction walks -the rollout's context events $C$ --- not its generation turns ---- so the wire format is grounded in the substrate's -append-only log rather than in a denormalised turn view. -Concretely: the function groups \texttt{RunContextEvent}~rows -by \texttt{worker\_binding\_key}, builds the prompt from the -initial \texttt{system\_prompt}/\texttt{user\_message} events, -and then walks the remaining events in sequence order. -Model-authored events (\texttt{assistant\_text}, -\texttt{tool\_call}, \texttt{thinking}) contribute their -\texttt{turn\_token\_ids} to \texttt{completion\_ids} with -\texttt{env\_mask=1} and their \texttt{turn\_logprobs} to -\texttt{logprobs}; environment events (\texttt{tool\_result}) -contribute tokenised text to \texttt{completion\_ids} with -\texttt{env\_mask=0} and zero-padded \texttt{logprobs}. The -scalar \texttt{reward} is attached by -\texttt{RewardStrategy.assign} (\texttt{extraction.py:L103}) -from the run's \texttt{RunTaskEvaluation} rows. Because -$\tau_E$ is event-sourced, the entire IR is reproducible at -export time against the pinned Postgres row versions --- the -same rollout replayed tomorrow produces byte-identical IR. - -\paragraph{Adapters.} Each adapter is a shim that calls -\texttt{submit}, polls \texttt{\{batch\_id\}}, and maps the -returned \texttt{Trajectory} into the native batch type the -trainer expects. Table~\ref{tab:adapters} summarises; the -longest file is $\sim$90 lines. - -\begin{table}[h] -\centering -\small -\caption{Trainer adapters. All three are thin shims over the -shared \texttt{/rollouts} endpoints; the framework-specific -work is field renaming and wrapping in the native batch type.} -\label{tab:adapters} -\begin{tabular}{@{}l>{\raggedright\arraybackslash}p{0.24\linewidth}>{\raggedright\arraybackslash}p{0.20\linewidth}>{\raggedright\arraybackslash}p{0.34\linewidth}@{}} -\toprule -\textbf{Trainer} & \textbf{Adapter file} & \textbf{Output type} & \textbf{Field renames and quirks} \\ -\midrule -TRL (GRPO) & \texttt{trl\_http.py:L25-L91} & \texttt{dict} (GRPOTrainer contract) & \texttt{reward}~$\to$~\texttt{completion\_reward}; synchronous \texttt{httpx.Client}; batch of $n$ trajectories per call. \\ -VERL & \texttt{verl\_http.py:L23-L82} & \texttt{AgentLoopOutput} & \texttt{completion\_ids} $\to$ \texttt{response\_ids}; \texttt{env\_mask} $\to$ \texttt{response\_mask}; async; single episode per call (streaming integration). \\ -OpenRLHF & \texttt{openrlhf\_http.py}\allowbreak\texttt{:L24-L84} & \texttt{dict} (\texttt{input\_ids}, \texttt{response\_ids}, \texttt{logprobs}, \texttt{reward}) & Drops \texttt{env\_mask} (OpenRLHF's return shape does not carry turn masks); module-level \texttt{configure()} for one-time setup; async; single episode per call. \\ -\bottomrule -\end{tabular} -\end{table} - -Because the trainers consume the same \texttt{Trajectory} -contract, a model trained under one trainer can be evaluated -against another trainer's harness without re-exporting: the -Ergon substrate is written once, and the format difference is -absorbed in the adapter layer. A new trainer is added by -writing one adapter file; no changes to the substrate, the -server, or the existing adapters are required. The repository -ships no SkyRL adapter at submission time, but the pattern -supports one at the same cost as the existing three. - - -% ---------------------------------------------------------------------------- -% Appendix E --- Tech-Stack Integrations List. -% Reframed (v5.6) from v4's Extended Community / Framework Capability Matrix. -% Purpose: descriptive list of what Ergon plugs into / exports to --- at the -% agents layer (Pydantic AI, LangGraph, CrewAI, AutoGen, Google ADK, Claude -% Code), RL layer (TRL, VERL, OpenRLHF, SkyRL, ProRL Agent, AReaL, AgentGym-RL, -% RAGEN, MARTI, etc.), and trajectory-format export targets (the five -% projections' canonical formats). Not a normative capability matrix; a -% descriptive integrations map. -% Content: written fresh in Session 11, filling in real integration status. -% Framing: "Ergon hosts existing agent frameworks (X, Y, Z) via adapters, -% feeds existing RL trainers (A, B, C) via projections, and exports to the -% five community canonical trajectory formats surveyed in Sec.~2.2." -% ---------------------------------------------------------------------------- - -\section{Tech-Stack Integrations} -\label{app:integrations} - -This appendix is a descriptive integrations \emph{map}, not a -normative capability matrix. For each of three layers --- agent -frameworks the substrate hosts as inner runtimes, RL trainers -the substrate feeds via HTTP adapters, and canonical trajectory -formats the substrate projects to --- we list the concrete -integrations shipped at submission time, together with the -integrations on the near-term roadmap. Rows marked -\emph{integrated} are exercised by the experiments in -\S\ref{sec:validation} or by the Ergon test suite; rows marked -\emph{planned} are scoped in RFCs in the Ergon repository under -\texttt{docs/rfcs/active/} and do not contribute to any result -reported in this paper. - -\paragraph{Agents layer.} Ergon hosts third-party agent loops -as inner runtimes behind an \texttt{AgentRuntimeAdapter} -contract: the adapter wraps the framework's loop, serialises -each turn as a \texttt{RunContextEvent} (see -\S\ref{app:system:postgres}), and replays stored events to -reconstruct framework-native message state on resumption. One -adapter ships today; five are scoped -(Table~\ref{tab:integrations:agents}). - -\begin{table}[h] -\centering -\small -\setlength{\tabcolsep}{4pt} -\caption{Agent-framework integrations. Integrated rows are -exercised by the flexible-agent worker. Planned rows are scoped -in the referenced RFC in the Ergon repository.} -\label{tab:integrations:agents} -\begin{tabular}{@{}l l >{\raggedright\arraybackslash}p{0.42\linewidth}@{}} -\toprule -\textbf{Framework} & \textbf{Status} & \textbf{Evidence / RFC} \\ -\midrule -Pydantic AI & integrated & \texttt{workers/baselines/}\allowbreak\texttt{react\_worker.py:L28-L105} wraps \texttt{Agent.iter()}; context replay at \texttt{persistence/context/}\allowbreak\texttt{assembly.py:L94-L130} \\ -LangGraph & planned & \texttt{agent-framework-adapter-layer} (state-graph replay) \\ -CrewAI & planned & \texttt{agent-framework-adapter-layer} (task-delegation shim) \\ -AutoGen & planned & \texttt{agent-framework-adapter-layer} (per-agent \texttt{worker\_binding\_key}) \\ -Google ADK & planned & \texttt{agent-framework-adapter-layer} (state-machine replay) \\ -Claude Code & planned & \texttt{agent-framework-adapter-layer} (closest native fit) \\ -\bottomrule -\end{tabular} -\end{table} - -\paragraph{RL trainers.} Ergon serves a FastAPI -\texttt{/rollouts} surface consumed by framework-specific -shims; the handshake is documented in Appendix~\ref{app:verl}. -Three shims ship today --- TRL, VERL, OpenRLHF --- each -$\sim$80--90 lines. Six are scoped -(Table~\ref{tab:integrations:trainers}). - -\begin{table}[h] -\centering -\small -\setlength{\tabcolsep}{4pt} -\caption{RL-trainer integrations. Integrated rows are the three -adapters compared in Table~\ref{tab:adapters}. Planned rows are -scoped in \texttt{rl-trainer-adapter-expansion}, which also -formalises the HTTP handshake as a \texttt{TrainerHttpAdapter} -\mbox{Protocol}.} -\label{tab:integrations:trainers} -\begin{tabular}{@{}l l >{\raggedright\arraybackslash}p{0.42\linewidth}@{}} -\toprule -\textbf{Trainer} & \textbf{Status} & \textbf{Evidence / RFC} \\ -\midrule -TRL (GRPO) & integrated & \texttt{adapters/trl\_http.py:L1-L92} \\ -VERL & integrated & \texttt{adapters/verl\_http.py:L1-L83} (\texttt{@register("ergon")}) \\ -OpenRLHF & integrated & \texttt{adapters/openrlhf\_http.py:L1-L85} \\ -SkyRL & planned & \texttt{rl-trainer-adapter-expansion} \\ -ProRL Agent & planned & \texttt{rl-trainer-adapter-expansion} \\ -AReaL & planned & \texttt{rl-trainer-adapter-expansion} \\ -AgentGym-RL & planned & \texttt{rl-trainer-adapter-expansion} \\ -RAGEN & planned & \texttt{rl-trainer-adapter-expansion} \\ -MARTI & planned & \texttt{rl-trainer-adapter-expansion} \\ -\bottomrule -\end{tabular} -\end{table} - -\paragraph{Projection / export formats.} The five canonical -trajectory shapes surveyed in \S\ref{sec:system:format} each -correspond to a projection operator over the substrate. Two -ship today (step-indexed tuples and per-agent streams, both -produced by \texttt{extract\_agent\_trajectories} at -\texttt{core/rl/extraction.py:L49-L117}); three are scoped -(Table~\ref{tab:integrations:projections}). The three planned -projections are pure reads over the existing schema -(option-tagged, call-tree) or reserve a new annotation -namespace (MCTS) --- no DB migration is required for any of -them. - -\begin{table}[h] -\centering -\small -\setlength{\tabcolsep}{4pt} -\caption{Projection-operator integrations. The five shapes map -to the trajectory-format survey in \S\ref{sec:system:format}. -Planned rows are one RFC each.} -\label{tab:integrations:projections} -\begin{tabular}{@{}l l >{\raggedright\arraybackslash}p{0.42\linewidth}@{}} -\toprule -\textbf{Projection} & \textbf{Status} & \textbf{Evidence / RFC} \\ -\midrule -Step-indexed tuples & integrated & \texttt{core/rl/extraction.py:L49-L117} \\ -Per-agent streams & integrated & same, keyed by \texttt{worker\_binding\_key} \\ -Option-tagged (semi-MDP) & planned & \texttt{projection-operator-option-tagged} \\ -Call-tree (nested) & planned & \texttt{projection-operator-call-tree} \\ -MCTS search-tree & planned & \texttt{projection-operator-mcts} (\texttt{mcts.*} annotation namespace) \\ -\bottomrule -\end{tabular} -\end{table} - -Of the twenty rows in the three tables, six are integrated and -fourteen are planned. The integrated subset is sufficient for -the experiments reported in \S\ref{sec:validation}; the planned -subset is what Ergon must ship to fully back the substrate -framing of \S\ref{sec:system}. Each planned RFC scopes the -code additions required and notes the paper-parity dependency -explicitly. - -% ---------------------------------------------------------------------------- -% Appendix F --- Experimental Setup. Consolidates former appendices F (Agent -% Action Spaces), G (Benchmark Details), H (Flexible Agent and Projection -% Details), and I (Cross-harness Reconciliation Methodology) into one -% themed appendix. Old \section labels are preserved as \label aliases on -% the new subsections so body refs (\ref{app:reconciliation}, -% \ref{app:benchmarks}) continue to resolve. -% ---------------------------------------------------------------------------- - -\section{Experimental Setup} -\label{app:setup} - -This appendix consolidates the experimental-setup details supporting -\S\ref{sec:validation}: per-benchmark agent action spaces -(\S\ref{app:actions}), benchmark selection and statistics -(\S\ref{app:benchmarks}), the flexible-agent scaffold used as the -workload-generating system (\S\ref{app:fivescaffolds}), and the -cross-harness reconciliation methodology for the SWE-Bench Verified -comparison (\S\ref{app:reconciliation}). - -\subsection{Agent action spaces} -\label{app:actions} - -Table~\ref{tab:actions} lists the full action space available to -the flexible-agent worker on each benchmark. Every benchmark -agent has access to the four subtask-decomposition tools (shared -across all benchmarks); task-specific tools vary. - -\begin{table}[h] -\centering -\small -\caption{Per-benchmark agent action space. The four -subtask-decomposition tools are shared across all benchmarks; -remaining tools are task-specific.} -\label{tab:actions} -\begin{tabular}{@{}lll@{}} -\toprule -\textbf{Benchmark} & \textbf{Tool} & \textbf{What it does} \\ -\midrule -\textit{All} & \texttt{spawn\_subtask} & Create a child subtask with a description and required inputs \\ -\textit{All} & \texttt{cancel\_subtask} & Cancel a subtask by ID (late cancellation is distinct from completion) \\ -\textit{All} & \texttt{wait\_on\_subtask} & Block on a subtask ID until it reaches a terminal status \\ -\textit{All} & \texttt{report\_result} & Write this task's result and mark the task complete \\ -\midrule -MiniF2F & \texttt{lean\_repl} & \todo{short description} \\ -MiniF2F & \texttt{lean\_check} & \todo{short description} \\ -\midrule -Research Rubrics & \texttt{web\_search} & \todo{short description (Tavily)} \\ -Research Rubrics & \texttt{read\_document} & \todo{short description (httpx+trafilatura)} \\ -\midrule -SWE-Bench Verified & \texttt{bash} & \todo{short description} \\ -SWE-Bench Verified & \texttt{edit\_file} & \todo{short description} \\ -\bottomrule -\end{tabular} -\end{table} - -The subtask-decomposition tools are exposed uniformly: the -worker's system prompt describes them mechanically and gives no -guidance on when to use them, so observed delegation structure -comes from the agent rather than from prompting (see -\S\ref{sec:validation:setup}). - - -\subsection{Benchmark details} -\label{app:benchmarks} - -\placeholder{0.95}{% - \textbf{Table A2: MiniF2F problem selection.} - Difficulty levels, problem categories, proof-length statistics, - subset used in \S\ref{sec:validation}. -} - -\placeholder{0.95}{% - \textbf{Table A3: Research Rubrics question distribution.} - Topic distribution, rubric criteria counts, evaluator - configuration. -} - -\placeholder{0.95}{% - \textbf{Table A4: SWE-Bench Verified subset.} - Per-repository counts in the 100--150 instance subset used in - \S\ref{sec:validation}, sampling procedure, comparison to full - Verified statistics, E2B template details. -} - - -\subsection{Flexible agent and projection details} -\label{app:fivescaffolds} - -\subsubsection{Benchmark delegation toolkit} -\label{app:benchmark-action-space} - -The flexible-agent worker of \S\ref{sec:validation} is given a -particular set of delegation actions that mutate the substrate. -This action space is a design choice of our benchmark, not a -feature of Ergon itself --- another research team could expose a -different set of delegation actions against the same substrate -and run a different benchmark. The toolkit we expose consists of -six verbs, each corresponding to a named mutation pattern on -$(N, E, A)$: -\begin{itemize}\itemsep 0pt - \item \texttt{add\_subtask}: creates a single child node and - dispatches it for asynchronous execution. - \item \texttt{plan\_subtasks}: atomically creates a sub-DAG of - children validated by Kahn's algorithm, with root children - dispatched to a concurrency-15 executor. - \item \texttt{cancel\_task}: marks a node terminal, cascades to - descendants, and invalidates outgoing dependency edges - (cancelled sub-trees remain in $(N, E)$ for analysis). - \item \texttt{refine\_task}: edits a non-running node's - description with field-history preserved. - \item \texttt{restart\_task}: resets a terminal node to - \textsc{pending} and cascades invalidation to downstream - dependencies. - \item \texttt{list\_subtasks} and \texttt{get\_subtask}: - read-only observations of the current containment tree. -\end{itemize} -Each verb maps to a named mutation type in $M$, so downstream -evaluators distinguish delegation, cancellation, and refinement -from ordinary tool use without custom parsing --- independently -of what specific verbs our benchmark chose to expose. - -\subsubsection{Agent details and projections} - -\todo{For the flexible-agent worker of \S\ref{sec:validation}: -system prompt, full tool inventory (benchmark action space above -plus task-specific tools), one-shot example if any, and the -turn-budget and stopping rules. For each of the five projection -operators $\pi_{\text{step}}$, $\pi_{\text{per-agent}}$, -$\pi_{\text{call-tree}}$, $\pi_{\text{macro}}$, -$\pi_{\text{json-log}}$: one worked example on one trajectory -from each task family, the drops manifest it produces, and -discussion of which behavioural metrics each projection -structurally cannot preserve.} - - -% ---------------------------------------------------------------------------- -% Appendix I (Dataset Onboarding Guide), Appendix J (Automated Experimental -% Infrastructure), and Appendix K (Fault-Injection Methodology) were cut in -% 2026-04-21. Rationale: all three had zero body-text citations (or the one -% K cite was rewritten to point at Appendix D's WAL/mutation-log invariants). -% I was a how-to-use-Ergon tutorial that belongs in the repo README; J was -% orchestration lore that belongs in the repo README; K (fault injection) is -% not part of the new experiment setup. Downstream appendices (formerly L/M/N) -% are automatically relabelled to I/J/K by LaTeX's \section counter; no -% manual renumbering needed. -% ---------------------------------------------------------------------------- - - -\subsection{Cross-harness reconciliation methodology} -\label{app:reconciliation} - -\todo{Session 11: Cross-harness reconciliation methodology appendix. Source: Weekend 1 Sec.~4.5 experiment. -Structure: (a) convention specification --- union convention justified against alternatives (SWE-agent's convention, Agentless's convention, minimal common denominator); (b) download and re-grading code walkthrough; (c) what the re-grading preserves vs what it cannot preserve (explicit drops manifest for the reconciliation convention itself); (d) drops manifests for the SWE-agent $\to$ rollout-card and Agentless $\to$ rollout-card ingestions per Sec.~4.2 ``Cross-harness reconciliation''; (e) \texttt{no\_generation} convention analysis --- 50 instances SWE-agent, 4 Agentless; why Convention A (exclude) and Convention B (include as zero) bracket the reasonable range of harness-level choices.} - -% ---------------------------------------------------------------------------- -% Appendix J (Additional Rollout Traces) was cut in 2026-04-21. The per- -% benchmark trace figures (Fig A3 MiniF2F, Fig A4 Research Rubrics, Fig A5 -% SWE-Bench Verified) were dropped --- they add rollout colour but don't -% back any claim. The crown-jewel six-projection figure (formerly Fig A2) -% was relocated to Appendix C (app:projections) as Fig~\ref{fig:sixproj} -% since it is the visual statement of the projection-loss argument that -% appendix makes in prose. LaTeX auto-renumbers the Benchmark Card below -% from appendix K to J. -% ---------------------------------------------------------------------------- - -% ---------------------------------------------------------------------------- -% Appendix G --- Behavioural Quantities (moved from §4.2 body on 2026-04-22 -% per signposting / length pass; see proposed_edits_signposting.md C6). -% ---------------------------------------------------------------------------- - -\section{Behavioural Quantities} -\label{app:quantities} - -This appendix catalogues the six behavioural quantities -referenced in \S\ref{sec:validation:setup}. Each is a -canonical analysis of one of the five research communities -surveyed in \S\ref{sec:problem:communities}. The set is -illustrative: a rollout card supports arbitrarily many -analyses, and the three quantities exercised in -\S\ref{sec:validation:results} (abandonment ratio by depth, -per-agent sibling embedding distance, and the -\texttt{no\_generation} split in the SWE-bench reconciliation) -are drawn from this set. - -\begin{table}[h] -\centering -\small -\caption{Six behavioural quantities exercised in -\S\ref{sec:validation:results}, each the canonical analysis of -one of the five research communities from -\S\ref{sec:problem:communities}. The set is illustrative: a -rollout card supports arbitrarily many such analyses.} -\label{tab:quantities} -\begin{tabular}{@{}p{0.32\textwidth}p{0.28\textwidth}p{0.32\textwidth}@{}} -\toprule -\textbf{Quantity} & \textbf{Community} & \textbf{Computed from} \\ -\midrule -Step-indexed return under episode mask & Long-horizon LLM-agentic RL \citep{chen2025loop, wang2025ragen} & \texttt{events.jsonl} token spans \\ -Option termination frequency & HRL / macro-action \citep{bacon2017optioncritic} & \texttt{mutations.jsonl} status transitions \\ -Call-tree depth distribution & Recursive LMs \citep{zhu2024redel} & \texttt{nodes.jsonl} \texttt{parent\_id} chains \\ -Per-agent sibling embedding distance & Fixed-role MAS \citep{cemri2025mast} & \texttt{nodes.jsonl} + \texttt{events.jsonl} \\ -Node visit entropy across frontier & MCTS-based training \citep{rstarmath2025, feng2024restmcts} & \texttt{annotations.jsonl} (MCTS namespace) \\ -Dispatch-and-wait rate & Async MAS & \texttt{events.jsonl} + \texttt{mutations.jsonl} \\ -\bottomrule -\end{tabular} -\end{table} - -% ---------------------------------------------------------------------------- -% Appendix J --- Benchmark Card: Evaluative Role, Assumptions, Limitations. -% CARRY VERBATIM. -% ---------------------------------------------------------------------------- - -\section{Benchmark Card: Evaluative Role, Assumptions, and Limitations} -\label{app:benchmarkcard} - -\paragraph{Evaluative role.} -The Ergon benchmark suite, paired with the trajectory -representation and projection operators, supports evaluative -claims about dynamic-delegation agents that were previously not -comparable across research communities: (i) whether an agent -uses concurrent dispatch when it is available; (ii) whether an -agent cancels sub-tasks that are not making progress; (iii) -whether an agent's delegation structure contains dependency -diamonds that exceed tree-shape formalisms; (iv) whether the -agent's reasoning expresses intent that its recorded structural -actions fail to realise; (v) whether a community's preferred -trajectory format preserves each of these behaviours under its -projection operator. - -\paragraph{Assumptions.} -\begin{itemize} - \item The tool implementations (web search, document retrieval, - Lean REPL, SWE-Bench harness) are stable at their released - versions and latency distributions; substantial drift in - tool-provider behaviour may invalidate fault-injection - calibration. - \item The rubric-based evaluator (GPT-4o-mini, for Research - Rubrics) is treated as a noisy but calibrated judge; we do not - claim evaluator infallibility and provide inter-rater agreement - statistics in Appendix~\ref{app:benchmarks}. - \item The flexible-agent worker is given access to the - subtask-decomposition tools but not prompted to use them; - observed delegation structure reflects the backbone model's - untutored decomposition under a minimal system prompt, not a - researcher-imposed strategy. -\end{itemize} - -\paragraph{Limitations.} -\begin{itemize} - \item Three task families; generalisation claims to other - long-horizon settings should be made cautiously. The - behavioural coverage of \S\ref{sec:validation} is complementary - but not exhaustive. - \item LLM-judge failure modes (reward hacking, distribution - shift) are documented but not fully characterised. - \item Tool-provider API drift may require re-calibration; - reproducibility bundle pins tool-provider snapshots. SWE-Bench - Verified's per-repository environment specs are pinned to the - \texttt{swebench} package version used. -\end{itemize} - -\paragraph{Intended use.} -Evaluation of dynamic-delegation policies via the substrate and -projection-preservation framework. The benchmark suite is \emph{not} -intended as a stand-alone prover, a factual QA benchmark, or a -general agentic capability test; claims in those directions should -use purpose-built benchmarks and metrics. - -\paragraph{Failure modes.} -Rubric reward hacking on Research Rubrics; MiniF2F proof leakage -from the backbone's training data; SWE-Bench test-pattern overfit -on popular repositories; tool-provider API drift; LLM-judge -sycophancy. - - - -% ============================================================================ -% Mandatory NeurIPS 2026 Paper Checklist -% ============================================================================ -\input{checklist.tex} - -\end{document} diff --git a/ergon_paper_overleaf_edit/neurips_2026.sty b/ergon_paper_overleaf_edit/neurips_2026.sty deleted file mode 100644 index c2ac0132..00000000 --- a/ergon_paper_overleaf_edit/neurips_2026.sty +++ /dev/null @@ -1,437 +0,0 @@ -% partial rewrite of the LaTeX2e package for submissions to the -% Conference on Neural Information Processing Systems (NeurIPS): -% -% - uses more LaTeX conventions -% - line numbers at submission time replaced with aligned numbers from -% lineno package -% - \nipsfinalcopy replaced with [final] package option -% - automatically loads times package for authors -% - loads natbib automatically; this can be suppressed with the -% [nonatbib] package option -% - adds foot line to first page identifying the conference -% - adds preprint option for submission to e.g. arXiv -% - conference acronym modified -% - update foot line to display the track name -% -% Roman Garnett (garnett@wustl.edu) and the many authors of -% nips15submit_e.sty, including MK and drstrip@sandia -% -% last revision: January 2026 - -\NeedsTeXFormat{LaTeX2e} -\ProvidesPackage{neurips_2026}[2026-01-29 NeurIPS 2026 submission/camera-ready style file] - -% declare final option, which creates camera-ready copy -\newif\if@neuripsfinal\@neuripsfinalfalse -\DeclareOption{final}{ - \@neuripsfinaltrue - \@anonymousfalse -} - -% declare nonatbib option, which does not load natbib in case of -% package clash (users can pass options to natbib via -% \PassOptionsToPackage) -\newif\if@natbib\@natbibtrue -\DeclareOption{nonatbib}{ - \@natbibfalse -} - -% declare preprint option, which creates a preprint version ready for -% upload to, e.g., arXiv -\newif\if@preprint\@preprintfalse -\DeclareOption{preprint}{ - \@preprinttrue - \@anonymousfalse -} - -% determine the track of the paper in camera-ready mode -\newif\if@main\@maintrue -\DeclareOption{main}{ - \@maintrue - \newcommand{\@trackname}{\@neuripsordinal\ Conference on Neural Information Processing Systems (NeurIPS \@neuripsyear).} -} -\newif\if@position\@positionfalse -\DeclareOption{position}{ - \@positiontrue - \newcommand{\@trackname}{\@neuripsordinal\ Conference on Neural Information Processing Systems (NeurIPS \@neuripsyear). Position Paper Track.} -} -\newif\if@eandd\@eanddfalse -\DeclareOption{eandd}{ - \@eanddtrue -\if@neuripsfinal\@anonymousfalse\else\if@preprint\@anonymousfalse\else\@anonymoustrue\fi\fi - \newcommand{\@trackname}{\@neuripsordinal\ Conference on Neural Information Processing Systems (NeurIPS \@neuripsyear). Track on Evaluations and Datasets.} -} -\newif\if@creativeai\@creativeaifalse -\DeclareOption{creativeai}{ - \@creativeaitrue - \@anonymousfalse - \newcommand{\@trackname}{\@neuripsordinal\ Conference on Neural Information Processing Systems (NeurIPS \@neuripsyear). Creative AI Track.} -} - -% For anonymous or non-anonymous -\newif\if@anonymous\@anonymoustrue - -% For workshop papers -\newcommand{\@workshoptitle}{} -\newcommand{\workshoptitle}[1]{\renewcommand{\@workshoptitle}{#1}} - -\newif\if@workshop\@workshopfalse -\DeclareOption{sglblindworkshop}{ - \@workshoptrue - \@anonymousfalse - \newcommand{\@trackname}{\@neuripsordinal\ Conference on Neural Information Processing Systems (NeurIPS \@neuripsyear). Workshop: \@workshoptitle.} -} -\DeclareOption{dblblindworkshop}{ - \@workshoptrue - \newcommand{\@trackname}{\@neuripsordinal\ Conference on Neural Information Processing Systems (NeurIPS \@neuripsyear). Workshop: \@workshoptitle.} -} -\DeclareOption{nonanonymous}{ - \@anonymousfalse -} - -\ProcessOptions\relax - -% fonts -\renewcommand{\rmdefault}{ptm} -\renewcommand{\sfdefault}{phv} - -% change this every year for notice string at bottom -\newcommand{\@neuripsordinal}{40th} -\newcommand{\@neuripsyear}{2026} -\newcommand{\@neuripslocation}{Sydney} - -% acknowledgments -\usepackage{environ} -\newcommand{\acksection}{\section*{Acknowledgments and Disclosure of Funding}} -\NewEnviron{ack}{% - \acksection - \BODY -} - - -% load natbib unless told otherwise -\if@natbib - \RequirePackage{natbib} -\fi - - - - - -% set page geometry -\usepackage[verbose=true,letterpaper]{geometry} -\AtBeginDocument{ - \newgeometry{ - textheight=9in, - textwidth=5.5in, - top=1in, - headheight=12pt, - headsep=25pt, - footskip=30pt - } - \@ifpackageloaded{fullpage} - {\PackageWarning{neurips_2026}{fullpage package not allowed! Overwriting formatting.}} - {} -} - -\widowpenalty=10000 -\clubpenalty=10000 -\flushbottom -\sloppy - - -% font sizes with reduced leading -\renewcommand{\normalsize}{% - \@setfontsize\normalsize\@xpt\@xipt - \abovedisplayskip 7\p@ \@plus 2\p@ \@minus 5\p@ - \abovedisplayshortskip \z@ \@plus 3\p@ - \belowdisplayskip \abovedisplayskip - \belowdisplayshortskip 4\p@ \@plus 3\p@ \@minus 3\p@ -} -\normalsize -\renewcommand{\small}{% - \@setfontsize\small\@ixpt\@xpt - \abovedisplayskip 6\p@ \@plus 1.5\p@ \@minus 4\p@ - \abovedisplayshortskip \z@ \@plus 2\p@ - \belowdisplayskip \abovedisplayskip - \belowdisplayshortskip 3\p@ \@plus 2\p@ \@minus 2\p@ -} -\renewcommand{\footnotesize}{\@setfontsize\footnotesize\@ixpt\@xpt} -\renewcommand{\scriptsize}{\@setfontsize\scriptsize\@viipt\@viiipt} -\renewcommand{\tiny}{\@setfontsize\tiny\@vipt\@viipt} -\renewcommand{\large}{\@setfontsize\large\@xiipt{14}} -\renewcommand{\Large}{\@setfontsize\Large\@xivpt{16}} -\renewcommand{\LARGE}{\@setfontsize\LARGE\@xviipt{20}} -\renewcommand{\huge}{\@setfontsize\huge\@xxpt{23}} -\renewcommand{\Huge}{\@setfontsize\Huge\@xxvpt{28}} - - -% Force \tiny to be no smaller than 6pt -\renewcommand{\tiny}{\fontsize{6pt}{7pt}\selectfont} - -% Force \scriptsize to be no smaller than 7pt -\renewcommand{\scriptsize}{\fontsize{7pt}{8pt}\selectfont} - -% Force \footnotesize to be no smaller than 8pt -\renewcommand{\footnotesize}{\fontsize{8pt}{9.5pt}\selectfont} - -% sections with less space -\providecommand{\section}{} -\renewcommand{\section}{% - \@startsection{section}{1}{\z@}% - {-2.0ex \@plus -0.5ex \@minus -0.2ex}% - { 1.5ex \@plus 0.3ex \@minus 0.2ex}% - {\large\bf\raggedright}% -} -\providecommand{\subsection}{} -\renewcommand{\subsection}{% - \@startsection{subsection}{2}{\z@}% - {-1.8ex \@plus -0.5ex \@minus -0.2ex}% - { 0.8ex \@plus 0.2ex}% - {\normalsize\bf\raggedright}% -} -\providecommand{\subsubsection}{} -\renewcommand{\subsubsection}{% - \@startsection{subsubsection}{3}{\z@}% - {-1.5ex \@plus -0.5ex \@minus -0.2ex}% - { 0.5ex \@plus 0.2ex}% - {\normalsize\bf\raggedright}% -} -\providecommand{\paragraph}{} -\renewcommand{\paragraph}{% - \@startsection{paragraph}{4}{\z@}% - {1.5ex \@plus 0.5ex \@minus 0.2ex}% - {-1em}% - {\normalsize\bf}% -} -\providecommand{\subparagraph}{} -\renewcommand{\subparagraph}{% - \@startsection{subparagraph}{5}{\z@}% - {1.5ex \@plus 0.5ex \@minus 0.2ex}% - {-1em}% - {\normalsize\bf}% -} -\providecommand{\subsubsubsection}{} -\renewcommand{\subsubsubsection}{% - \vskip5pt{\noindent\normalsize\rm\raggedright}% -} - -% float placement -\renewcommand{\topfraction }{0.85} -\renewcommand{\bottomfraction }{0.4} -\renewcommand{\textfraction }{0.1} -\renewcommand{\floatpagefraction}{0.7} - -\newlength{\@neuripsabovecaptionskip}\setlength{\@neuripsabovecaptionskip}{7\p@} -\newlength{\@neuripsbelowcaptionskip}\setlength{\@neuripsbelowcaptionskip}{\z@} - -\setlength{\abovecaptionskip}{\@neuripsabovecaptionskip} -\setlength{\belowcaptionskip}{\@neuripsbelowcaptionskip} - -% swap above/belowcaptionskip lengths for tables -\renewenvironment{table} - {\setlength{\abovecaptionskip}{\@neuripsbelowcaptionskip}% - \setlength{\belowcaptionskip}{\@neuripsabovecaptionskip}% - \@float{table}} - {\end@float} - -% footnote formatting -\setlength{\footnotesep }{6.65\p@} -\setlength{\skip\footins}{9\p@ \@plus 4\p@ \@minus 2\p@} -\renewcommand{\footnoterule}{\kern-3\p@ \hrule width 12pc \kern 2.6\p@} -\setcounter{footnote}{0} - -% paragraph formatting -\setlength{\parindent}{\z@} -\setlength{\parskip }{5.5\p@} - -% list formatting -\setlength{\topsep }{4\p@ \@plus 1\p@ \@minus 2\p@} -\setlength{\partopsep }{1\p@ \@plus 0.5\p@ \@minus 0.5\p@} -\setlength{\itemsep }{2\p@ \@plus 1\p@ \@minus 0.5\p@} -\setlength{\parsep }{2\p@ \@plus 1\p@ \@minus 0.5\p@} -\setlength{\leftmargin }{3pc} -\setlength{\leftmargini }{\leftmargin} -\setlength{\leftmarginii }{2em} -\setlength{\leftmarginiii}{1.5em} -\setlength{\leftmarginiv }{1.0em} -\setlength{\leftmarginv }{0.5em} -\def\@listi {\leftmargin\leftmargini} -\def\@listii {\leftmargin\leftmarginii - \labelwidth\leftmarginii - \advance\labelwidth-\labelsep - \topsep 2\p@ \@plus 1\p@ \@minus 0.5\p@ - \parsep 1\p@ \@plus 0.5\p@ \@minus 0.5\p@ - \itemsep \parsep} -\def\@listiii{\leftmargin\leftmarginiii - \labelwidth\leftmarginiii - \advance\labelwidth-\labelsep - \topsep 1\p@ \@plus 0.5\p@ \@minus 0.5\p@ - \parsep \z@ - \partopsep 0.5\p@ \@plus 0\p@ \@minus 0.5\p@ - \itemsep \topsep} -\def\@listiv {\leftmargin\leftmarginiv - \labelwidth\leftmarginiv - \advance\labelwidth-\labelsep} -\def\@listv {\leftmargin\leftmarginv - \labelwidth\leftmarginv - \advance\labelwidth-\labelsep} -\def\@listvi {\leftmargin\leftmarginvi - \labelwidth\leftmarginvi - \advance\labelwidth-\labelsep} - -% create title -\providecommand{\maketitle}{} -\renewcommand{\maketitle}{% - \par - \begingroup - \renewcommand{\thefootnote}{\fnsymbol{footnote}} - % for perfect author name centering - \renewcommand{\@makefnmark}{\hbox to \z@{$^{\@thefnmark}$\hss}} - % The footnote-mark was overlapping the footnote-text, - % added the following to fix this problem (MK) - \long\def\@makefntext##1{% - \parindent 1em\noindent - \hbox to 1.8em{\hss $\m@th ^{\@thefnmark}$}##1 - } - \thispagestyle{empty} - \@maketitle - \@thanks - \@notice - \endgroup - \let\maketitle\relax - \let\thanks\relax -} - -% rules for title box at top of first page -\newcommand{\@toptitlebar}{ - \hrule height 4\p@ - \vskip 0.25in - \vskip -\parskip% -} -\newcommand{\@bottomtitlebar}{ - \vskip 0.29in - \vskip -\parskip - \hrule height 1\p@ - \vskip 0.09in% -} - -% create title (includes both anonymized and non-anonymized versions) -\providecommand{\@maketitle}{} -\renewcommand{\@maketitle}{% - \vbox{% - \hsize\textwidth - \linewidth\hsize - \vskip 0.1in - \@toptitlebar - \centering - {\LARGE\bf \@title\par} - \@bottomtitlebar - \if@anonymous - \begin{tabular}[t]{c}\bf\rule{\z@}{24\p@} - Anonymous Author(s) \\ - Affiliation \\ - Address \\ - \texttt{email} \\ - \end{tabular}% - \else - \def\And{% - \end{tabular}\hfil\linebreak[0]\hfil% - \begin{tabular}[t]{c}\bf\rule{\z@}{24\p@}\ignorespaces% - } - \def\AND{% - \end{tabular}\hfil\linebreak[4]\hfil% - \begin{tabular}[t]{c}\bf\rule{\z@}{24\p@}\ignorespaces% - } - \begin{tabular}[t]{c}\bf\rule{\z@}{24\p@}\@author\end{tabular}% - \fi - \vskip 0.3in \@minus 0.1in - } -} - -% add conference notice to bottom of first page -\newcommand{\ftype@noticebox}{8} -\newcommand{\@notice}{% - % give a bit of extra room back to authors on first page - \enlargethispage{2\baselineskip}% - \@float{noticebox}[b]% - \footnotesize\@noticestring% - \end@float% -} - -% abstract styling -\renewenvironment{abstract}% -{% - \vskip 0.075in% - \centerline% - {\large\bf Abstract}% - \vspace{0.5ex}% - \begin{quote}% -} -{ - \par% - \end{quote}% - \vskip 1ex% -} - -% For the paper checklist -\newcommand{\answerYes}[1][]{\textcolor{blue}{[Yes]#1}} -\newcommand{\answerNo}[1][]{\textcolor{orange}{[No]#1}} -\newcommand{\answerNA}[1][]{\textcolor{gray}{[N/A]#1}} -\newcommand{\answerTODO}[1][]{\textcolor{red}{\bf [TODO]}} -\newcommand{\justificationTODO}[1][]{\textcolor{red}{\bf [TODO]}} - -% handle tweaks for camera-ready copy vs. submission copy -\if@preprint - \newcommand{\@noticestring}{% - Preprint.% - } -\else - \if@neuripsfinal - \newcommand{\@noticestring}{ - \@trackname - } - \else - \newcommand{\@noticestring}{% - Submitted to \@neuripsordinal\/ Conference on Neural Information Processing Systems (NeurIPS \@neuripsyear). Do not distribute.% - } - - % hide the acknowledgements - \NewEnviron{hide}{} - \let\ack\hide - \let\endack\endhide - - % line numbers for submission - \RequirePackage{lineno} - \linenumbers - - % fix incompatibilities between lineno and amsmath, if required, by - % transparently wrapping linenomath environments around amsmath - % environments - \AtBeginDocument{% - \@ifpackageloaded{amsmath}{% - \newcommand*\patchAmsMathEnvironmentForLineno[1]{% - \expandafter\let\csname old#1\expandafter\endcsname\csname #1\endcsname - \expandafter\let\csname oldend#1\expandafter\endcsname\csname end#1\endcsname - \renewenvironment{#1}% - {\linenomath\csname old#1\endcsname}% - {\csname oldend#1\endcsname\endlinenomath}% - }% - \newcommand*\patchBothAmsMathEnvironmentsForLineno[1]{% - \patchAmsMathEnvironmentForLineno{#1}% - \patchAmsMathEnvironmentForLineno{#1*}% - }% - \patchBothAmsMathEnvironmentsForLineno{equation}% - \patchBothAmsMathEnvironmentsForLineno{align}% - \patchBothAmsMathEnvironmentsForLineno{flalign}% - \patchBothAmsMathEnvironmentsForLineno{alignat}% - \patchBothAmsMathEnvironmentsForLineno{gather}% - \patchBothAmsMathEnvironmentsForLineno{multline}% - } - {} - } - \fi -\fi - - -\endinput diff --git a/ergon_paper_overleaf_edit/references.bib b/ergon_paper_overleaf_edit/references.bib deleted file mode 100644 index 615f262f..00000000 --- a/ergon_paper_overleaf_edit/references.bib +++ /dev/null @@ -1,916 +0,0 @@ -% ============================================================================ -% Ergon paper references -% Organised by role in the paper: -% A. LLM RL infrastructure (the capability/taxonomy table) -% B. Five-communities citations (§2 fragmentation) -% C. MARL theory anchors -% D. Evaluation-substrate precedents (Gym lineage) -% E. Event sourcing / durable execution -% F. Positioning / related work (distinguishing citations) -% G. Prompting-era origin cites (footnote level) -% -% Entries marked with "% VERIFY" require author/date confirmation before -% submission. Entries marked with "% DROP-IF-UNUSED" should be removed if -% the draft does not actually cite them. -% ============================================================================ - -% ============================================================================ -% A. LLM RL Infrastructure -% ============================================================================ - -@misc{vonwerra2022trl, - title={{TRL}: Transformer Reinforcement Learning}, - author={von Werra, Leandro and Belkada, Younes and Tunstall, Lewis and Beeching, Edward and Thrush, Tristan and Lambert, Nathan and Huang, Shengyi}, - year={2022}, - publisher={GitHub}, - howpublished={\url{https://github.com/huggingface/trl}}, -} - -@inproceedings{sheng2025verl, - title={{HybridFlow}: A Flexible and Efficient {RLHF} Framework}, - author={Sheng, Guangming and Cao, Chi and Gao, Zilingfeng and others}, - booktitle={EuroSys}, - year={2025}, -} - -@article{xi2025agentgym, - title={{AgentGym-RL}: Training {LLM} Agents for Long-Horizon Decision Making through Multi-Turn Reinforcement Learning}, - author={Xi, Zhiheng and others}, - journal={arXiv preprint arXiv:2509.08755}, - year={2025}, -} - -@article{wang2025ragen, - title={{RAGEN}: Understanding Self-Evolution in {LLM} Agents via Multi-Turn Reinforcement Learning}, - author={Wang, Zihan and others}, - journal={arXiv preprint arXiv:2504.20073}, - year={2025}, -} - -@misc{marti2025, - title={{MARTI}: A Framework for Multi-Agent {LLM} Systems Reinforced Training and Inference}, - author={{TsinghuaC3I}}, - year={2025}, - howpublished={\url{https://github.com/TsinghuaC3I/MARTI}}, - note={GitHub repository}, -} - -@article{mei2025areal, - title={{AReaL}: A Large-Scale Asynchronous Reinforcement Learning System for Language Reasoning}, - author={Mei, Zhiyu and others}, - journal={arXiv preprint arXiv:2505.24298}, - year={2025}, -} - -@misc{primeintellect_primerl, - title={{PRIME-RL}: Agentic {RL} Training at Scale}, - author={{Prime Intellect}}, - year={2025}, - howpublished={\url{https://github.com/PrimeIntellect-ai/prime-rl}}, - note={GitHub repository; see also INTELLECT-3 technical report, arXiv:2512.16144}, -} - -@misc{brown2025verifiers, - title={{Verifiers}: Environments for {LLM} Reinforcement Learning}, - author={Brown, William}, - year={2025}, - howpublished={\url{https://github.com/PrimeIntellect-ai/verifiers}}, - note={Core abstraction is \texttt{MultiTurnEnv}, a single-agent rollout loop}, -} - -@article{cao2025skyrlagent, - title={{SkyRL-Agent}: Efficient {RL} Training for Multi-turn {LLM} Agent}, - author={Cao, Shiyi and Li, Dacheng and Zhao, Fangzhou and Yuan, Shuo and Hegde, Sumanth R. and Chen, Connor and Ruan, Charlie and Griggs, Tyler and Liu, Shu and Tang, Eric and Liaw, Richard and Moritz, Philipp and Zaharia, Matei and Gonzalez, Joseph E. and Stoica, Ion}, - journal={arXiv preprint arXiv:2511.16108}, - year={2025}, -} - -@misc{comlrl2025, - title={{CoMLRL}: Cooperative Multi-{LLM} Reinforcement Learning}, - author={{CoMLRL Contributors}}, - year={2025}, - howpublished={\url{https://openmlrl.github.io/CoMLRL/}}, - note={Decentralised cooperative {MARL} training for {LLMs}; Dec-POMDP formalism}, -} - -@article{recollab2025, - title={{ReCoLLAB}: Retrieval-Augmented {LLMs} for Cooperative Ad-hoc Teammate Modeling}, - author={Anonymous}, - journal={arXiv preprint arXiv:2512.22129}, - year={2025}, - note={Uses Overcooked + policy libraries; no LLM-RL training framework}, -} - -@article{liu2025gem, - title={{GEM}: A Gym for Agentic {LLMs}}, - author={Liu, Jian and Sims, Matthew and Duan, Jiaxin and others}, - journal={arXiv preprint}, - year={2025}, - note={VERIFY exact venue/date}, -} - -@article{silver2025welcome, - title={Welcome to the Era of Experience}, - author={Silver, David and Sutton, Richard S.}, - journal={Preprint}, - year={2025}, -} - -% SkyRL, OpenRLHF, ProRL-Agent: VERIFY exact citations before submission. -@article{skyrl2025, - title={{SkyRL}-Agent: An End-to-End {RL} Framework for Long-Horizon {LLM} Agents}, - author={Anonymous}, - journal={arXiv preprint arXiv:2511.16108}, - year={2025}, - note={VERIFY authorship}, -} - -@misc{openrlhf, - title={{OpenRLHF}: An Easy-to-Use, Scalable and High-Performance {RLHF} Framework}, - author={OpenRLHF Team}, - year={2024}, - howpublished={\url{https://github.com/OpenRLHF/OpenRLHF}}, -} - -@article{zhang2026prorlagent, - title={{ProRL Agent}: Rollout-as-a-Service for {RL} Training of Multi-Turn {LLM} Agents}, - author={Zhang, Hao and others}, - journal={arXiv preprint arXiv:2603.18815}, - year={2026}, - note={NVIDIA; integrated into NeMo Gym; decouples rollout lifecycle from training loop}, -} - - -@article{yu2025dapo, - title={{DAPO}: An Open-Source {LLM} Reinforcement Learning System at Scale}, - author={Yu, Qiying and others}, - journal={arXiv preprint arXiv:2503.14476}, - year={2025}, - note={Built on verl; single-agent math reasoning RL}, -} - -@article{wang2026ragenv2, - title={{RAGEN}-v2: Understanding Reasoning Collapse in Multi-Turn Agent Reinforcement Learning}, - author={Wang, Zihan and Gui, Chi and others}, - journal={arXiv preprint}, - year={2026}, - note={Documents failure modes of single-agent long-horizon RL}, -} - -@misc{nemogym2025, - title={{NeMo-RL}: A Scalable and Efficient Post-Training Library}, - author={{NVIDIA}}, - year={2025}, - howpublished={\url{https://github.com/NVIDIA-NeMo/RL}}, -} - -% ============================================================================ -% B. Five-communities citations (§2 fragmentation) -% ============================================================================ - -% --- Anchor for long-horizon framing --- - -@article{kwa2025metr, - title={Measuring {AI} Ability to Complete Long Tasks}, - author={Kwa, Thomas and West, Ben and Becker, Joel and Deng, Amy and Garcia, Katharyn and Hasin, Max and Jawhar, Sami and Kinniment, Megan and Rush, Nate and Von Arx, Sydney and others}, - journal={arXiv preprint arXiv:2503.14499}, - year={2025}, - note={{METR} time-horizon trajectory; single-agent task horizons doubling every $\sim$7 months}, -} - -% --- Community 1: Long-horizon LLM-agentic RL --- - -@article{chen2025loop, - title={Reinforcement Learning for Long-Horizon Interactive {LLM} Agents}, - author={Chen, Kevin and Cusumano-Towner, Marco and Huval, Brody and Petrenko, Aleksei and Hamburger, Jackson and Koltun, Vladlen and Kr{\"a}henb{\"u}hl, Philipp}, - journal={arXiv preprint arXiv:2502.01600}, - year={2025}, -} - -@article{odysseybench2025, - title={{OdysseyBench}: Evaluating {LLM} Agents on Long-Horizon Complex Office Application Workflows}, - author={Anonymous}, - journal={arXiv preprint arXiv:2508.09124}, - year={2025}, - note={VERIFY authorship}, -} - -% --- Community 2: Ad hoc teamwork --- - -@inproceedings{villin2025minimax, - title={A Minimax Approach to Ad Hoc Teamwork}, - author={Villin, Victor and Kleine Buening, Thomas and Dimitrakakis, Christos}, - booktitle={AAMAS}, - year={2025}, -} - -@article{wang2024naht, - title={{N}-Agent Ad Hoc Teamwork}, - author={Wang, Caroline and others}, - journal={NeurIPS}, - year={2024}, -} - -@article{zhang2025maht, - title={Multi-party Agent Relation Sampling for Multi-party Ad Hoc Teamwork}, - author={Zhang, Beiwen and Liang, Yongheng and Wu, Hejun}, - journal={arXiv preprint arXiv:2510.25340}, - year={2025}, -} - -@inproceedings{mirsky2022aht, - title={A Survey of Ad Hoc Teamwork Research}, - author={Mirsky, Reuth and Carlucho, Ignacio and Rahman, Arrasy and Fosong, Elliot and Macke, William and Sridharan, Mohan and Stone, Peter and Albrecht, Stefano V.}, - booktitle={European Workshop on Multi-Agent Systems (EUMAS)}, - year={2022}, -} - -@inproceedings{rahman2021openmarl, - title={Towards Open Ad Hoc Teamwork Using Graph-based Policy Learning}, - author={Rahman, Arrasy and H{\"o}pner, Niklas and Christianos, Filippos and Albrecht, Stefano V.}, - booktitle={ICML}, - year={2021}, -} - -@inproceedings{sun2025collaboverc, - title={{Collab-Overcooked}: Benchmarking and Evaluating Large Language Models as Collaborative Agents}, - author={Sun, Haochen and Zhang, Shuwen and Niu, Lujie and Ren, Lei and Xu, Hao and Fu, Hao and Zhao, Fangkun and Yuan, Caixia and Wang, Xiaojie}, - booktitle={Conference on Empirical Methods in Natural Language Processing (EMNLP)}, - year={2025}, - note={Survey Table 1 enumerates LLM-MAS benchmarks (RocoBench, VillagerBench, LLMARENA, CivRealm, BattleAgentBench, TDW-MAT, CuisineWorld) -- all classical-port, ad-hoc infrastructure}, -} - -@article{barrett2017plastic, - title={Making Friends on the Fly: Cooperating with New Teammates}, - author={Barrett, Samuel and Rosenfeld, Avi and Kraus, Sarit and Stone, Peter}, - journal={Artificial Intelligence}, - volume={242}, - pages={132--171}, - year={2017}, - note={Canonical {PLASTIC} policy-library method for AHT}, -} - -@article{ruhdorfer2025ogc, - title={The {O}vercooked {G}eneralisation {C}hallenge: Evaluating Cooperation under Environment and Partner Diversity}, - author={Ruhdorfer, Constantin and Boyle, Matthew and Albrecht, Stefano V.}, - journal={Transactions on Machine Learning Research (TMLR)}, - year={2025}, - note={JAX-accelerated Overcooked; integrates with JaxMARL and minimax}, -} - -% --- Community 3: Recursive language models --- - -@article{zhang2025rlm, - title={Recursive Language Models}, - author={Zhang, Alex L. and Kraska, Tim and Khattab, Omar}, - journal={arXiv preprint arXiv:2512.24601}, - year={2025}, -} - -@inproceedings{zhu2024redel, - title={{ReDel}: A Toolkit for {LLM}-Powered Recursive Multi-Agent Systems}, - author={Zhu, Andrew and Dugan, Liam and Callison-Burch, Chris}, - booktitle={EMNLP Demo Track}, - year={2024}, -} - -@misc{primeintellect_rlm2026, - title={Recursive Language Models: The Paradigm of 2026}, - author={{Prime Intellect}}, - year={2026}, - howpublished={\url{https://www.primeintellect.ai/blog/rlm}}, - note={Blog post}, -} - -@article{agentorchestra2025, - title={{AgentOrchestra}: A Hierarchical Multi-Agent Framework for General-Purpose Task Solving}, - author={Anonymous}, - journal={arXiv preprint arXiv:2506.12508}, - year={2025}, - note={VERIFY authorship}, -} - -@article{sun2025ctxfold, - title={Scaling Long-Horizon {LLM} Agent via Context-Folding}, - author={Sun, Weiwei and Lu, Miao and Ling, Zhe and Liu, Kai and Yao, Xin and Yang, Yi and Chen, Jing}, - journal={arXiv preprint arXiv:2510.11967}, - year={2025}, - note={Agent actively branches rollout and returns summary; inference-time decomposition}, -} - -@article{ye2025agentfold, - title={{AgentFold}: Long-Horizon Web Agents with Proactive Context Management}, - author={Ye, Rui and Zhang, Zhijie and Li, Kuan and Yin, Haoyu and Tao, Zhiyi and Zhao, Yaqi and Su, Liangtao and Zhang, Liang and Qiao, Zhen and Wang, Xuanjing and others}, - journal={arXiv preprint arXiv:2510.24699}, - year={2025}, - note={Context folding via multi-scale state summaries; inference-time}, -} - -@inproceedings{schroeder2025thread, - title={{THREAD}: Thinking Deeper with Recursive Spawning}, - author={Schroeder, Philip and Grand, Gabriel and Dafny, Nathaniel and Kim, Yoon and Andreas, Jacob}, - booktitle={NeurIPS}, - year={2025}, - note={Recursive sub-LM spawning; inference-time only}, -} - -@article{grand2025discipl, - title={Self-Steering Language Models}, - author={Grand, Gabriel and Pepe, Joshua B. and Andreas, Jacob and Tenenbaum, Joshua B.}, - journal={arXiv preprint arXiv:2504.07081}, - year={2025}, - note={DisCIPL; planner-LM generates inference programs; no training}, -} - -@article{yu2025memagent, - title={{MemAgent}: Reshaping Long-Context {LLM} with Multi-Conv {RL}-Based Memory Agent}, - author={Yu, Hongli and Chen, Tinghong and Feng, Jiangtao and Chen, Jiarui and Dai, Wenbin and Yu, Qi and Zhang, Yi and Ma, Wei and Liu, Jingjing and Wang, Minlie and Zhou, Hao}, - journal={arXiv preprint arXiv:2507.02259}, - year={2025}, - note={RL-trained context management; still single-agent}, -} - -% --- Community 4: Classical hierarchical / MacDec-POMDP RL --- - -@inproceedings{bacon2017optioncritic, - title={The Option-Critic Architecture}, - author={Bacon, Pierre-Luc and Harb, Jean and Precup, Doina}, - booktitle={AAAI}, - year={2017}, -} - -@article{amato2019macdec, - title={Modeling and Planning with Macro-Actions in Decentralized {POMDPs}}, - author={Amato, Christopher and Konidaris, George and Kaelbling, Leslie Pack and How, Jonathan P.}, - journal={Journal of Artificial Intelligence Research}, - volume={64}, - pages={817--859}, - year={2019}, -} - -@article{xiao2022asynchronous, - title={Asynchronous Actor-Critic for Multi-Agent Reinforcement Learning}, - author={Xiao, Yuchen and Tan, Weihao and Amato, Christopher}, - journal={NeurIPS}, - year={2022}, - note={arXiv:2209.10113}, -} - -@inproceedings{jung2025acac, - title={Agent-Centric Actor-Critic for Asynchronous Multi-Agent Reinforcement Learning}, - author={Jung, Whiyoung and Hong, Sunghoon and Yoon, Deunsol and Lee, Kanghoon and Lim, Woohyung}, - booktitle={ICML}, - year={2025}, - note={PMLR 267:28481--28502}, -} - -@inproceedings{vezhnevets2017feudal, - title={{FeUdal} Networks for Hierarchical Reinforcement Learning}, - author={Vezhnevets, Alexander Sasha and Osindero, Simon and Schaul, Tom and Heess, Nicolas and Jaderberg, Max and Silver, David and Kavukcuoglu, Koray}, - booktitle={ICML}, - year={2017}, - note={Manager/Worker decomposition; canonical deep HRL architecture}, -} - -@article{pateria2021hrl, - title={Hierarchical Reinforcement Learning: A Comprehensive Survey}, - author={Pateria, Shubham and Subagdja, Budhitama and Tan, Ah-hwee and Quek, Chai}, - journal={ACM Computing Surveys}, - volume={54}, - number={5}, - pages={1--35}, - year={2021}, -} - -@article{bacciu2024fgrl, - title={{F}eudal Graph Reinforcement Learning}, - author={Bacciu, Davide and Errica, Federico and Galanti, Tomer and Micheli, Alessio and Cini, Andrea}, - journal={arXiv preprint arXiv:2304.05099}, - year={2024}, - note={Extends feudal RL to graph neural networks; non-LLM}, -} - -% --- Community 5: Production agent orchestration (infrastructure, not RL) --- - -@misc{claudecode2024, - title={Claude Code: Terminal-Based Agentic Coding Tool}, - author={Anthropic}, - year={2024}, - howpublished={\url{https://docs.claude.com/en/docs/claude-code}}, -} - -@misc{anthropic2025subagents, - title={Subagents in the Claude Agent {SDK}}, - author={Anthropic}, - year={2025}, - howpublished={\url{https://platform.claude.com/docs/en/agent-sdk/subagents}}, - note={Isolated context windows; summary-only return to parent; no durable persistence}, -} - -@misc{shihipar2026claudeagent, - title={Building Agents with the Claude Agent {SDK}}, - author={Shihipar, Thariq}, - year={2026}, - howpublished={\url{https://www.anthropic.com/engineering/building-agents-with-the-claude-agent-sdk}}, - note={Engineering blog; canonical description of sub-agent design rationale}, -} - -@misc{googleadk2025, - title={Agent Development Kit ({ADK})}, - author={Google}, - year={2025}, - howpublished={\url{https://google.github.io/adk-docs/}}, -} - -@misc{langgraph2024, - title={{LangGraph}: Building Stateful, Multi-Actor Applications with {LLMs}}, - author={{LangChain}}, - year={2024}, - howpublished={\url{https://langchain-ai.github.io/langgraph/}}, -} - -@misc{crewai2024, - title={{CrewAI}: Framework for Orchestrating Role-Playing Autonomous {AI} Agents}, - author={CrewAI}, - year={2024}, - howpublished={\url{https://www.crewai.com/}}, -} - -@inproceedings{wu2024autogen, - title={{AutoGen}: Enabling Next-Gen {LLM} Applications via Multi-Agent Conversation}, - author={Wu, Qingyun and others}, - booktitle={COLM}, - year={2024}, -} - -% --- Anchor: Masters et al. POSG formalism (self-citation, double-blind safe) --- - -@article{masters2025workflowposg, - title={A {POSG} Formulation of Autonomous Workflow Management}, - author={Anonymous}, - journal={arXiv preprint arXiv:2510.02557}, - year={2025}, - note={Cited anonymously per double-blind policy; to be de-anonymised on acceptance}, -} - -% ============================================================================ -% C. MARL Theory Anchors -% ============================================================================ - -@book{albrecht2024marl, - title={Multi-Agent Reinforcement Learning: Foundations and Modern Approaches}, - author={Albrecht, Stefano V. and Christianos, Filippos and Sch{\"a}fer, Lukas}, - publisher={MIT Press}, - year={2024}, -} - -@inproceedings{hansen2004posg, - title={Dynamic Programming for Partially Observable Stochastic Games}, - author={Hansen, Eric A. and Bernstein, Daniel S. and Zilberstein, Shlomo}, - booktitle={AAAI}, - year={2004}, -} - -@inproceedings{terry2021pettingzoo, - title={{PettingZoo}: Gym for Multi-Agent Reinforcement Learning}, - author={Terry, J. K. and Black, Benjamin and Grammel, Nathaniel and others}, - booktitle={NeurIPS Datasets and Benchmarks}, - year={2021}, -} - -@article{rutherford2024jaxmarl, - title={{JaxMARL}: Multi-Agent {RL} Environments and Algorithms in {JAX}}, - author={Rutherford, Alexander and Ellis, Benjamin and others}, - journal={NeurIPS Datasets and Benchmarks}, - year={2024}, -} - -% ============================================================================ -% D. Evaluation-Substrate Precedents (Gym lineage) -% ============================================================================ - -@article{brockman2016gym, - title={{OpenAI} Gym}, - author={Brockman, Greg and Cheung, Vicki and Pettersson, Ludwig and Schneider, Jonas and Schulman, John and Tang, Jie and Zaremba, Wojciech}, - journal={arXiv preprint arXiv:1606.01540}, - year={2016}, -} - -@article{towers2024gymnasium, - title={{Gymnasium}: A Standard Interface for Reinforcement Learning Environments}, - author={Towers, Mark and others}, - journal={arXiv preprint arXiv:2407.17032}, - year={2024}, -} - -% ============================================================================ -% E. Event Sourcing / Durable Execution -% ============================================================================ - -@misc{fowler2005event, - title={Event Sourcing}, - author={Fowler, Martin}, - year={2005}, - howpublished={\url{https://martinfowler.com/eaaDev/EventSourcing.html}}, -} - -@misc{temporal2024, - title={Temporal: Durable Execution Platform}, - author={{Temporal Technologies}}, - year={2024}, - howpublished={\url{https://temporal.io/}}, -} - -@misc{inngest2024, - title={{Inngest}: Event-Driven Durable Workflows for Developers}, - author={Inngest}, - year={2024}, - howpublished={\url{https://www.inngest.com/}}, -} - -% ============================================================================ -% F. Positioning / Distinguishing Citations -% ============================================================================ - -@article{rstarmath2025, - title={{rStar-Math}: Small {LLMs} Can Master Math Reasoning with Self-Evolved Deep Thinking}, - author={Guan, Xinyu and others}, - journal={arXiv preprint arXiv:2501.04519}, - year={2025}, -} - -@article{restmcts2024, - title={{ReST-MCTS*}: {LLM} Self-Training via Process Reward Guided Tree Search}, - author={Zhang, Dan and others}, - journal={arXiv preprint arXiv:2406.03816}, - year={2024}, -} - -@article{bengio2021gflow, - title={{GFlowNet} Foundations}, - author={Bengio, Yoshua and Lahlou, Salem and Deleu, Tristan and Hu, Edward J. and Tiwari, Mo and Bengio, Emmanuel}, - journal={arXiv preprint arXiv:2111.09266}, - year={2021}, -} - -@inproceedings{malkin2022trajbalance, - title={Trajectory Balance: Improved Credit Assignment in {GFlowNets}}, - author={Malkin, Nikolay and Jain, Moksh and Bengio, Emmanuel and Sun, Chen and Bengio, Yoshua}, - booktitle={NeurIPS}, - year={2022}, -} - -@article{liu2023lostmiddle, - title={Lost in the Middle: How Language Models Use Long Contexts}, - author={Liu, Nelson F. and Lin, Kevin and Hewitt, John and Paranjape, Ashwin and Bevilacqua, Michele and Petroni, Fabio and Liang, Percy}, - journal={Transactions of the Association for Computational Linguistics}, - year={2024}, - note={Empirical evidence that effective context is much smaller than nominal context}, -} - -@article{hsieh2024ruler, - title={{RULER}: What's the Real Context Size of Your Long-Context Language Models?}, - author={Hsieh, Cheng-Ping and Sun, Simeng and Kriman, Samuel and Acharya, Shantanu and Rekesh, Dima and Jia, Fei and Ginsburg, Boris}, - journal={arXiv preprint arXiv:2404.06654}, - year={2024}, -} - -@article{shao2024deepseekmath, - title={{DeepSeekMath}: Pushing the Limits of Mathematical Reasoning in Open Language Models}, - author={Shao, Zhihong and Wang, Peiyi and Zhu, Qihao and Xu, Runxin and Song, Junxiao and Bi, Xiao and Zhang, Haowei and Zhang, Mingchuan and Li, Y.K. and Wu, Y. and Guo, Daya}, - journal={arXiv preprint arXiv:2402.03300}, - year={2024}, - note={Introduces Group Relative Policy Optimization (GRPO)}, -} - -@article{yao2023tot, - title={Tree of Thoughts: Deliberate Problem Solving with Large Language Models}, - author={Yao, Shunyu and Yu, Dian and Zhao, Jeffrey and Shafran, Izhak and Griffiths, Thomas L. and Cao, Yuan and Narasimhan, Karthik}, - journal={NeurIPS}, - year={2023}, -} - -@article{deepseekr1, - title={{DeepSeek-R1}: Incentivizing Reasoning Capability in {LLMs} via Reinforcement Learning}, - author={{DeepSeek-AI}}, - journal={arXiv preprint arXiv:2501.12948}, - year={2025}, -} - -@article{searchr1, - title={{Search-R1}: Training {LLMs} to Reason and Leverage Search Engines with Reinforcement Learning}, - author={Jin, Bowen and others}, - journal={arXiv preprint arXiv:2503.09516}, - year={2025}, -} - -@article{webrl2024, - title={{WebRL}: Training {LLM} Web Agents via Self-Evolving Online Curriculum Reinforcement Learning}, - author={Qi, Zehan and others}, - journal={arXiv preprint arXiv:2411.02337}, - year={2024}, -} - -@article{deepresearcher2025, - title={{DeepResearcher}: Scaling Deep Research via Reinforcement Learning in Real-World Environments}, - author={Zheng, Yuxiang and others}, - journal={arXiv preprint arXiv:2504.03160}, - year={2025}, -} - -% ============================================================================ -% G. Positioning: Agent Benchmarks -% ============================================================================ - -@article{liu2023agentbench, - title={{AgentBench}: Evaluating {LLMs} as Agents}, - author={Liu, Xiao and Yu, Hao and Zhang, Hanchen and others}, - journal={arXiv preprint arXiv:2308.03688}, - year={2023}, -} - -@article{jimenez2024swebench, - title={{SWE-bench}: Can Language Models Resolve Real-World {GitHub} Issues?}, - author={Jimenez, Carlos E. and Yang, John and Wettig, Alexander and others}, - journal={ICLR}, - year={2024}, -} - -@article{zheng2022minif2f, - title={{MiniF2F}: a cross-system benchmark for formal {Olympiad-level} mathematics}, - author={Zheng, Kunhao and Han, Jesse Michael and Polu, Stanislas}, - journal={ICLR}, - year={2022}, -} - -@article{ada2023llmp, - title={{Ada}: Learning Adaptive Planning Representations with Natural Language Guidance}, - author={Wong, Lionel and Mao, Jiayuan and Sharma, Pratyusha and Siegel, Zachary S. and Feng, Jiahai and Korneev, Noa and Tenenbaum, Joshua B. and Andreas, Jacob}, - journal={arXiv preprint arXiv:2312.08566}, - year={2023}, -} - -@article{appworld2024, - title={{AppWorld}: A Controllable World of Apps and People for Benchmarking Interactive Coding Agents}, - author={Trivedi, Harsh and others}, - journal={ACL}, - year={2024}, -} - -% ============================================================================ -% H. Prompting-Era Origin Cites -% ============================================================================ - -@article{yao2023react, - title={{ReAct}: Synergizing Reasoning and Acting in Language Models}, - author={Yao, Shunyu and Zhao, Jeffrey and Yu, Dian and Du, Nan and Shafran, Izhak and Narasimhan, Karthik and Cao, Yuan}, - journal={ICLR}, - year={2023}, -} - -@article{khot2023decomp, - title={Decomposed Prompting: A Modular Approach for Solving Complex Tasks}, - author={Khot, Tushar and others}, - journal={ICLR}, - year={2023}, -} - -@article{sheng2024hybridflow, - title={{HybridFlow}: A Flexible and Efficient {RLHF} Framework}, - author={Sheng, Guangming and Zhang, Chi and Ye, Zilingfeng and Wu, Xibin and Zhang, Wang and Zhang, Ru and Peng, Yanghua and Lin, Haibin and Wu, Chuan}, - journal={arXiv preprint arXiv:2409.19256}, - year={2024}, -} - -@article{yehudai2025agenteval, - title={Survey on Evaluation of {LLM}-based Agents}, - author={Yehudai, Asaf and Eden, Lilach and Li, Alan and Uziel, Guy and Zhao, Yilun and Bar-Haim, Roy and Cohan, Arman and Shmueli-Scheuer, Michal}, - journal={arXiv preprint arXiv:2503.16416}, - year={2025}, -} - -@article{jiang2025verltool, - title={{VerlTool}: Towards Holistic Agentic Reinforcement Learning with Tool Use}, - author={Jiang, Dongfu and Ji, Yi and Nguyen, Xuan-Phi and Zhuang, Yiran and Zhang, Quy-Anh and Li, Xiang and Chen, Wenhu}, - journal={arXiv preprint arXiv:2509.01055}, - year={2025}, -} - -@article{luo2025agentlightning, - title={Agent Lightning: Train ANY {AI} Agents with Reinforcement Learning}, - author={Luo, Xufang and Wei, Chengrui and Zhou, Yushuo and Wu, Menglin and Zhang, Wenyi and Yang, Yuge and Qiu, Xiao and Huang, Xu and Li, Dongsheng and Yang, Mao}, - journal={arXiv preprint arXiv:2508.03680}, - year={2025}, -} - -@inproceedings{cemri2025mast, - title={Why Do Multi-Agent {LLM} Systems Fail?}, - author={Cemri, Mert and Pan, Melissa Z and Yang, Shuyi and Agrawal, Lakshya A and Chopra, Bhavya and Tiwari, Rishabh and Keutzer, Kurt and Parameswaran, Aditya and Klein, Dan and Ramchandran, Kannan and Zaharia, Matei and Gonzalez, Joseph E and Stoica, Ion}, - booktitle={arXiv preprint arXiv:2503.13657}, - year={2025}, -} - -@article{zhang2024agentohana, - title={{AgentOhana}: Design Unified Data and Training Pipeline for Effective Agent Learning}, - author={Zhang, Jianguo and Lan, Tian and Murthy, Rithesh and Liu, Zhiwei and Yao, Weiran and Niebles, Juan Carlos and Wang, Huan and Xu, Ran and Xiong, Caiming}, - journal={arXiv preprint arXiv:2402.15506}, - year={2024}, -} - -@article{yang2025agentprotocols, - title={A Survey of {AI} Agent Protocols}, - author={Yang, Yingxuan and Chai, Huacan and Song, Yuanyi and Qi, Siyuan and Wen, Muning and Li, Ning and Liao, Junwei and Hu, Haoyi and Lin, Jianghao and Chang, Gaowei and Liu, Weiwen and Wen, Ying and Yu, Yong and Zhang, Weinan}, - journal={arXiv preprint arXiv:2504.16736}, - year={2025}, -} - -@article{suttonoptions, - title={Between {MDPs} and semi-{MDPs}: A framework for temporal abstraction in reinforcement learning}, - author={Sutton, Richard S and Precup, Doina and Singh, Satinder}, - journal={Artificial Intelligence}, - volume={112}, - number={1-2}, - pages={181--211}, - year={1999}, -} - -@inproceedings{bradtke1994smdp, - title={Reinforcement Learning Methods for Continuous-Time {M}arkov Decision Problems}, - author={Bradtke, Steven J. and Duff, Michael O.}, - booktitle={Advances in Neural Information Processing Systems 7 (NIPS 1994)}, - pages={393--400}, - year={1994}, -} - -@article{hoare1978csp, - title={Communicating Sequential Processes}, - author={Hoare, C. A. R.}, - journal={Communications of the {ACM}}, - volume={21}, - number={8}, - pages={666--677}, - year={1978}, -} - -@inproceedings{hewitt1973actor, - title={A Universal Modular {ACTOR} Formalism for Artificial Intelligence}, - author={Hewitt, Carl and Bishop, Peter and Steiger, Richard}, - booktitle={Proceedings of the 3rd International Joint Conference on Artificial Intelligence (IJCAI)}, - pages={235--245}, - year={1973}, -} - -@phdthesis{armstrong2003erlang, - title={Making Reliable Distributed Systems in the Presence of Software Errors}, - author={Armstrong, Joe}, - school={Royal Institute of Technology, Stockholm}, - year={2003}, -} - -@inproceedings{orseau2016interruptible, - title={Safely Interruptible Agents}, - author={Orseau, Laurent and Armstrong, Stuart}, - booktitle={Proceedings of the 32nd Conference on Uncertainty in Artificial Intelligence (UAI)}, - pages={557--566}, - year={2016}, -} - -@inproceedings{elmhamdi2017dynamicinterruptibility, - title={Dynamic Safe Interruptibility for Decentralized Multi-Agent Reinforcement Learning}, - author={El Mhamdi, El Mahdi and Guerraoui, Rachid and Hendrikx, Hadrien and Maurer, Alexandre}, - booktitle={Advances in Neural Information Processing Systems (NeurIPS)}, - year={2017}, -} - -@article{skiadopoulos2022dbos, - title={{DBOS}: A {DBMS}-oriented Operating System}, - author={Skiadopoulos, Athinagoras and Li, Qian and Kraft, Peter and Kraska, Kostis and Stonebraker, Michael and Zaharia, Matei and others}, - journal={Proceedings of the {VLDB} Endowment}, - volume={15}, - number={1}, - pages={21--30}, - year={2022}, -} - -@article{masters2024manageragent, - title={Orchestrating Human-{AI} Teams: The Manager Agent as a Unifying Research Challenge}, - author={Anonymous}, - booktitle={International Conference on Distributed Artificial Intelligence (DAI)}, - journal={arXiv preprint arXiv:2510.02557}, - year={2025}, - note={Cited anonymously per double-blind policy; to be de-anonymised on acceptance. Same paper as masters2025workflowposg, which is the POSG-formalism anchor.}, -} - -@article{biderman2024, - title={Lessons from the Trenches on Reproducible Evaluation of Language Models}, - author={Biderman, Stella and Schoelkopf, Hailey and Sutawika, Lintang and Gao, Leo and Tow, Jonathan and Abbasi, Baber and Aji, Alham Fikri and Ammanamanchi, Pawan Sasanka and Black, Sidney and Clive, Jordan and others}, - journal={arXiv preprint arXiv:2405.14782}, - year={2024}, -} - -@article{liu2025drgrpo, - title={Understanding R1-Zero-Like Training: A Critical Perspective}, - author={Liu, Zichen and Chen, Changyu and Li, Wenjun and Qi, Penghui and Pang, Tianyu and Du, Chao and Lee, Wee Sun and Lin, Min}, - journal={arXiv preprint arXiv:2503.20783}, - year={2025}, - note={Introduces Dr. GRPO; documents loss-aggregation convention gains of +7.3 to +15.7pp on AIME 2024.}, -} - -@misc{verl2165, - title={Training-engine and rollout-engine tokenization divergence on {Qwen3} {GRPO} ({verl} issue \#2165)}, - author={{verl}}, - year={2025}, - note={volcengine/verl issue \#2165, November 2025. Six independent reproductions; upstream-confirmed in QwenLM/Qwen3 \#1826.}, - howpublished={\url{https://github.com/volcengine/verl/issues/2165}}, -} - -@article{gebru2021datasheets, - title={Datasheets for Datasets}, - author={Gebru, Timnit and Morgenstern, Jamie and Vecchione, Briana and Vaughan, Jennifer Wortman and Wallach, Hanna and Iii, Hal Daumé and Crawford, Kate}, - journal={Communications of the ACM}, - volume={64}, - number={12}, - pages={86--92}, - year={2021}, - publisher={ACM New York, NY, USA}, -} - -@inproceedings{mitchell2019modelcards, - title={Model Cards for Model Reporting}, - author={Mitchell, Margaret and Wu, Simone and Zaldivar, Andrew and Barnes, Parker and Vasserman, Lucy and Hutchinson, Ben and Spitzer, Elena and Raji, Inioluwa Deborah and Gebru, Timnit}, - booktitle={Proceedings of the Conference on Fairness, Accountability, and Transparency}, - pages={220--229}, - year={2019}, -} - -@misc{beeching2023openllm, - title={What's going on with the Open {LLM} Leaderboard?}, - author={Beeching, Edward and Fourrier, Cl{\'e}mentine and Habib, Nathan and Han, Sheon and Lambert, Nathan and Rajani, Nazneen and Sanseviero, Omar and Tunstall, Lewis and Wolf, Thomas}, - year={2023}, - howpublished={\url{https://huggingface.co/blog/open-llm-leaderboard-mmlu}}, - note={Post-mortem analysis of the Open LLM Leaderboard MMLU variance on LLaMA-65B, LLaMA-30B, and Falcon-40B.}, -} - -@article{hu2025openrlhf, - title={{OpenRLHF}: An Easy-to-Use, Scalable and High-Performance {RLHF} Framework}, - author={Hu, Jian and Wu, Xibin and Zhu, Zilin and Xianyu and Wang, Weixun and Zhang, Dehao and Cao, Yu}, - journal={arXiv preprint arXiv:2501.03262}, - year={2025}, - note={Reports 3.13$\times$ wall-clock gap vs TRL on GSM8K-GRPO with identical model, hardware, and algorithm (Table 4).}, -} - -@misc{opentelemetry3163, - title={{GenAI}: semantic conventions for {LLM} cache-token accounting (pull request \#3163)}, - author={{OpenTelemetry}}, - year={2024}, - howpublished={\url{https://github.com/open-telemetry/semantic-conventions/pull/3163}}, - note={Formalises a convention disagreement between the OpenAI/Vertex cache-token model (cached tokens counted inside \texttt{input\_tokens}) and Anthropic's (cached tokens separated into \texttt{cache\_read\_input\_tokens} + \texttt{cache\_creation\_input\_tokens}).}, -} - -@misc{langfuse12306, - title={Anthropic cached-prompt token double-counting (issue \#12306)}, - author={{Langfuse}}, - year={2024}, - howpublished={\url{https://github.com/langfuse/langfuse/issues/12306}}, - note={Production-tracing instance of the cache-token convention disagreement documented in OpenTelemetry PR \#3163.}, -} - -@misc{nvidiaNemoGPQA, - title={{GPQA} dataset documentation: evaluation harness comparison}, - author={{NVIDIA}}, - year={2024}, - howpublished={\url{https://docs.nvidia.com/nemo-framework/user-guide/latest/llms/llama/gpqa.html}}, - note={States that simple-evals {GPQA} and \texttt{lm-evaluation-harness} {GPQA} are ``distinct, non-comparable metrics.''}, -} - -@misc{aiderLeaderboards, - title={Aider {LLM} leaderboards}, - author={{Aider}}, - year={2025}, - howpublished={\url{https://github.com/Aider-AI/aider/tree/main/aider/website/_data}}, - note={Same organisation publishes three leaderboards for Claude 3.5 Sonnet at different reported costs: Edit (\$0), Refactor (\$8.46), Polyglot (\$14.41), across \texttt{edit\_leaderboard.yml}, \texttt{refactor\_leaderboard.yml}, \texttt{polyglot\_leaderboard.yml}.}, -} - -@misc{swebenchHarness, - title={{SWE-bench} evaluation harness: timing backend divergence}, - author={{SWE-bench}}, - year={2024}, - howpublished={\url{https://github.com/princeton-nlp/SWE-bench}}, - note={Docker backend timer at \texttt{swebench/harness/docker\_utils.py:203-217} wraps bash-executed eval only; Modal backend timer at \texttt{swebench/harness/modal\_eval/run\_evaluation\_modal.py:307-319} wraps container startup + Python startup. Same logged metric, different code regions.}, -} - -@inproceedings{feng2024restmcts, - title={{ReST-MCTS*}: {LLM} Self-Training via Process Reward Guided Tree Search}, - author={Feng, Xidong and Wan, Ziyu and Wen, Muning and Wen, Ying and Zhang, Weinan and Wang, Jun}, - booktitle={Advances in Neural Information Processing Systems}, - year={2024}, - note={Cited in Sec.~4 for MCTS-based training community's native visit-entropy and abandoned-branch transforms.}, -} - -@misc{cernopendata, - title={{CERN} Open Data Portal}, - author={{CERN}}, - howpublished={\url{https://opendata.cern.ch}}, - year={2024}, - note={Public release of LHC detector-event records (CMS, ATLAS, LHCb, ALICE); cited as precedent for domain-wide observation-level publishing.}, -} diff --git a/scripts/smoke_local_up.sh b/scripts/smoke_local_up.sh index 263e9f55..e1d24e0d 100755 --- a/scripts/smoke_local_up.sh +++ b/scripts/smoke_local_up.sh @@ -48,6 +48,7 @@ Stack is up. Export these in your shell before running smoke: export ERGON_API_BASE_URL=http://127.0.0.1:9000 export PLAYWRIGHT_BASE_URL=http://127.0.0.1:3001 export ENABLE_TEST_HARNESS=1 + export ERGON_STARTUP_PLUGINS=ergon_core.test_support.smoke_fixtures:register_smoke_fixtures export TEST_HARNESS_SECRET=local-dev export SCREENSHOT_DIR=/tmp/playwright export E2B_API_KEY= # required for real sandbox runs diff --git a/tests/e2e/_asserts.py b/tests/e2e/_asserts.py index 05359cbe..44bb8484 100644 --- a/tests/e2e/_asserts.py +++ b/tests/e2e/_asserts.py @@ -29,7 +29,7 @@ from ergon_core.core.api.schemas import RunTaskDto from ergon_core.core.persistence.graph.models import RunGraphNode -from ergon_core.core.persistence.graph.status_conventions import COMPLETED +from ergon_core.core.persistence.graph.status_conventions import BLOCKED, COMPLETED, FAILED from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import ( RunResource, @@ -170,7 +170,9 @@ def _assert_sandbox_command_wal(run_id: UUID) -> None: ).all(), ) probes = [e for e in entries if "wc" in e.command or "probe" in e.command] - assert len(probes) >= 9, f"expected ≥9 probe WAL entries, got {len(probes)}" + # Canonical sad-path smokes block l_3 before it starts, so the eight + # executed leaves should emit probe commands while l_3 emits none. + assert len(probes) >= 8, f"expected ≥8 probe WAL entries, got {len(probes)}" def _assert_sandbox_lifecycle_events(run_id: UUID) -> None: @@ -293,8 +295,8 @@ def _assert_temporal_ordering(run_id: UUID) -> None: Uses ``RunTaskExecution.started_at`` / ``completed_at`` via ``node_id`` join. Only checks edges whose both endpoints reached - at least ``started`` state. The sad path still runs ``l_3`` because the - failing leaf completes the task with a score-zero output. + at least ``started`` state. Blocked descendants are skipped because + they should never have execution timestamps. """ with get_session() as s: leaves = list( @@ -366,11 +368,23 @@ def _assert_cohort_membership(cohort_key: str, run_ids: list[UUID]) -> None: def _assert_sadpath_graph_cascade(run_id: UUID) -> None: - """Score-zero sad path: all graph nodes complete, l_2 produces failed output.""" + """Canonical sad path: l_2 fails, l_3 blocks, independent leaves complete.""" snapshot = require_run_snapshot(run_id) - leaves = [task for task in snapshot.tasks.values() if task.level > 0] + tasks = list(snapshot.tasks.values()) + leaves = [task for task in tasks if task.level > 0] + root_tasks = [task for task in tasks if task.level == 0] by_slug = {task.name: task for task in leaves} - for slug in EXPECTED_SUBTASK_SLUGS: + assert len(root_tasks) == 1, f"expected 1 root task, got {len(root_tasks)}" + assert root_tasks[0].status != COMPLETED, ( + f"parent task should not complete when a child fails, got {root_tasks[0].status}" + ) + assert by_slug["l_2"].status == FAILED, f"l_2 expected FAILED, got {by_slug['l_2'].status}" + assert by_slug["l_3"].status == BLOCKED, f"l_3 expected BLOCKED, got {by_slug['l_3'].status}" + assert by_slug["l_3"].started_at is None, "blocked l_3 should never start" + assert not snapshot.executions_by_task.get(by_slug["l_3"].id), ( + "blocked l_3 should not have execution attempts" + ) + for slug in set(EXPECTED_SUBTASK_SLUGS) - {"l_2", "l_3"}: assert by_slug[slug].status == COMPLETED, ( f"{slug} expected COMPLETED, got {by_slug[slug].status}" ) @@ -427,28 +441,28 @@ def _assert_sadpath_partial_wal(run_id: UUID) -> None: def _assert_sadpath_thread_messages(run_id: UUID) -> None: - """Happy path sends 9 messages; sad l_2 suppresses completion reporting.""" + """Sad path sends messages for the 7 completed leaves only.""" snapshot = require_run_snapshot(run_id) thread = next( (thread for thread in snapshot.threads if thread.topic == "smoke-completion"), None ) assert thread is not None, "no smoke-completion thread created" msgs = sorted(thread.messages, key=lambda msg: msg.sequence_num) - assert len(msgs) == 8, f"expected 8 completion messages (l_2 suppressed), got {len(msgs)}" + assert len(msgs) == 7, f"expected 7 completion messages (l_2 failed, l_3 blocked), got {len(msgs)}" from_slugs = {m.from_agent_id.removeprefix("leaf-") for m in msgs} assert "l_2" not in from_slugs, ( f"l_2 sent a completion message despite suppression: {from_slugs}" ) - assert from_slugs == set(EXPECTED_SUBTASK_SLUGS) - {"l_2"} + assert "l_3" not in from_slugs, ( + f"l_3 sent a completion message despite being blocked: {from_slugs}" + ) + assert from_slugs == set(EXPECTED_SUBTASK_SLUGS) - {"l_2", "l_3"} def _assert_sadpath_evaluation(run_id: UUID) -> None: - """Reusing happy-path criterion on sad-path run must return score 0.""" + """Sad-path run should not produce a successful final score.""" snapshot = require_run_snapshot(run_id) - evals = list(snapshot.evaluations_by_task.values()) - assert len(evals) == 1 - assert evals[0].total_score == 0.0 - assert snapshot.final_score == 0.0 + assert snapshot.final_score in (None, 0.0) # ============================================================================= diff --git a/tests/e2e/_submit.py b/tests/e2e/_submit.py index e4468eea..1ffd3122 100644 --- a/tests/e2e/_submit.py +++ b/tests/e2e/_submit.py @@ -7,13 +7,12 @@ ergon internals, do not call ``build_experiment`` / ``create_run`` / ``inngest.send`` in-process, and do not register worker / evaluator slugs in the test process. All of that lives inside the api container -(see ``register_smoke_fixtures()`` called by ``app.py`` when -``ENABLE_SMOKE_FIXTURES=1``). Single source of truth for fixtures ⇒ no -host / container staleness risk. +(see ``ERGON_STARTUP_PLUGINS`` registering smoke fixtures in the API +container). Single source of truth for fixtures ⇒ no host / container +staleness risk. -Each slot can use a different ``(worker_slug, criterion_slug)`` pair — -used by the researchrubrics leg which has 2 happy + 1 sad slot. Empty -slots list is valid (returns ``[]``) but unlikely in practice. +Each slot can use a different ``(worker_slug, criterion_slug)`` pair. +Empty slots list is valid (returns ``[]``) but unlikely in practice. """ from __future__ import annotations diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index b5df287c..68bfd310 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -15,8 +15,7 @@ from sqlmodel import Session # NOTE: smoke fixture registration now lives exclusively inside the api -# container — see the conditional ``register_smoke_fixtures()`` call in -# ``ergon_core/core/api/app.py`` gated on ``ENABLE_SMOKE_FIXTURES=1``. +# container via ``ERGON_STARTUP_PLUGINS``. # Host-side pytest is a black-box client (``_submit.py`` → HTTP) and # doesn't need the fixtures in its own process. Keeping the registry # single-sourced eliminates the drift window where a fixture edit diff --git a/tests/e2e/test_minif2f_smoke.py b/tests/e2e/test_minif2f_smoke.py index 19f80b87..ddb17578 100644 --- a/tests/e2e/test_minif2f_smoke.py +++ b/tests/e2e/test_minif2f_smoke.py @@ -1,8 +1,4 @@ -"""MiniF2F canonical smoke — cohort of 3 happy runs against real E2B. - -No sad-path slot (researchrubrics leg carries that for the whole -matrix). Structure identical to ``test_researchrubrics_smoke.py``. -""" +"""MiniF2F canonical sad-path smoke against real E2B.""" from __future__ import annotations @@ -11,32 +7,29 @@ import os import pathlib import subprocess -import uuid from datetime import datetime, timezone import pytest from tests.e2e._asserts import ( - _assert_blob_roundtrip, _assert_cohort_membership, - _assert_minif2f_artifacts, - _assert_run_evaluation, - _assert_run_graph, - _assert_run_resources, - _assert_run_turn_counts, + _assert_sadpath_evaluation, + _assert_sadpath_graph_cascade, + _assert_sadpath_partial_artifact, + _assert_sadpath_partial_wal, + _assert_sadpath_thread_messages, _assert_sandbox_command_wal, _assert_sandbox_lifecycle_events, _assert_temporal_ordering, - _assert_thread_messages_ordered, - wait_for_terminal, + wait_for_terminal_status, ) from tests.e2e._submit import submit_cohort ENV = "minif2f" -WORKER = f"{ENV}-smoke-worker" +WORKER = f"{ENV}-sadpath-smoke-worker" CRITERION = f"{ENV}-smoke-criterion" -# ``SMOKE_COHORT_SIZE`` override for local dev; CI uses default 3. -COHORT_SIZE = int(os.environ.get("SMOKE_COHORT_SIZE", "3")) +# ``SMOKE_COHORT_SIZE`` override for local/dev deep checks; CI uses default 1. +COHORT_SIZE = int(os.environ.get("SMOKE_COHORT_SIZE", "1")) PER_RUN_TIMEOUT = 270 @@ -54,20 +47,25 @@ async def test_smoke_cohort(tmp_path: pathlib.Path) -> None: assert len(run_ids) == COHORT_SIZE await asyncio.gather( - *(wait_for_terminal(rid, timeout_seconds=PER_RUN_TIMEOUT) for rid in run_ids), + *( + wait_for_terminal_status( + rid, + expected_statuses=frozenset({"failed"}), + timeout_seconds=PER_RUN_TIMEOUT, + ) + for rid in run_ids + ), ) for rid in run_ids: - _assert_run_graph(rid) - _assert_run_resources(rid) - _assert_run_turn_counts(rid) - _assert_sandbox_command_wal(rid) + _assert_sadpath_graph_cascade(rid) + _assert_sadpath_partial_artifact(rid) + _assert_sadpath_partial_wal(rid) + _assert_sadpath_thread_messages(rid) + _assert_sadpath_evaluation(rid) _assert_sandbox_lifecycle_events(rid) - _assert_thread_messages_ordered(rid) - _assert_blob_roundtrip(rid) + _assert_sandbox_command_wal(rid) _assert_temporal_ordering(rid) - _assert_run_evaluation(rid) - _assert_minif2f_artifacts(rid) _assert_cohort_membership(cohort_key, run_ids) @@ -79,7 +77,7 @@ async def test_smoke_cohort(tmp_path: pathlib.Path) -> None: ) _invoke_playwright( cohort_key=cohort_key, - cohort=[{"run_id": str(rid), "kind": "happy"} for rid in run_ids], + cohort=[{"run_id": str(rid), "kind": "sad"} for rid in run_ids], screenshot_dir=screenshot_dir, ) diff --git a/tests/e2e/test_researchrubrics_smoke.py b/tests/e2e/test_researchrubrics_smoke.py index 912215a2..d032e801 100644 --- a/tests/e2e/test_researchrubrics_smoke.py +++ b/tests/e2e/test_researchrubrics_smoke.py @@ -1,15 +1,14 @@ -"""ResearchRubrics canonical smoke — cohort of 3 (2 happy + 1 sad) against real E2B. +"""ResearchRubrics canonical sad-path smoke against real E2B. Per-run assertion dispatch on slot ``kind``: -- ``happy`` slots run the full happy-path assertion block (§2.5 of - ``docs/superpowers/plans/test-refactor/02-drivers-and-asserts.md``). -- ``sad`` slot (slot 3) runs the sad-path block (§10) — line-cascade - failure invariants. +- The single slot routes ``l_2`` to a failing leaf. +- ``l_3`` depends on ``l_2`` and must remain blocked / unstarted. +- Independent branches must still complete. -Cohort-level: ``_assert_cohort_membership`` checks all 3 runs are -visible on ``/cohort/{key}``. Playwright subprocess runs at the end -with a JSON-encoded cohort array so the shared factory can dispatch +Cohort-level: ``_assert_cohort_membership`` checks all submitted runs +are visible on ``/cohort/{key}``. Playwright subprocess runs at the +end with a JSON-encoded cohort array so the shared factory can dispatch per-kind assertions in the UI. """ @@ -20,20 +19,12 @@ import os import pathlib import subprocess -import uuid -from dataclasses import dataclass from datetime import datetime, timezone -from typing import Literal import pytest from tests.e2e._asserts import ( - _assert_blob_roundtrip, _assert_cohort_membership, - _assert_run_evaluation, - _assert_run_graph, - _assert_run_resources, - _assert_run_turn_counts, _assert_sadpath_evaluation, _assert_sadpath_graph_cascade, _assert_sadpath_partial_artifact, @@ -42,45 +33,17 @@ _assert_sandbox_command_wal, _assert_sandbox_lifecycle_events, _assert_temporal_ordering, - _assert_thread_messages_ordered, - wait_for_terminal, wait_for_terminal_status, ) from tests.e2e._submit import submit_cohort ENV = "researchrubrics" -HAPPY_WORKER = f"{ENV}-smoke-worker" -SAD_WORKER = f"{ENV}-sadpath-smoke-worker" +WORKER = f"{ENV}-sadpath-smoke-worker" CRITERION = f"{ENV}-smoke-criterion" PER_RUN_TIMEOUT = 270 # seconds; < pytest's 300s --timeout -@dataclass(frozen=True) -class CohortSlot: - worker_slug: str - criterion_slug: str - kind: Literal["happy", "sad"] - - -def _build_cohort() -> tuple[CohortSlot, ...]: - """Build the cohort using the ``SMOKE_COHORT_SIZE`` env-var override. - - ``SMOKE_COHORT_SIZE`` controls the number of *happy* slots (default 2). - One sad-path slot is always appended — every cohort must exercise the - line-cascade failure path regardless of size. - - Size=1 → 1 happy + 1 sad. Size=2 (default) → 2 happy + 1 sad. - """ - size = int(os.environ.get("SMOKE_COHORT_SIZE", "2")) - if size <= 0: - raise ValueError(f"SMOKE_COHORT_SIZE must be >= 1, got {size}") - - slots: list[CohortSlot] = [CohortSlot(HAPPY_WORKER, CRITERION, "happy") for _ in range(size)] - slots.append(CohortSlot(SAD_WORKER, CRITERION, "sad")) - return tuple(slots) - - -COHORT: tuple[CohortSlot, ...] = _build_cohort() +COHORT_SIZE = int(os.environ.get("SMOKE_COHORT_SIZE", "1")) @pytest.mark.e2e @@ -88,41 +51,30 @@ def _build_cohort() -> tuple[CohortSlot, ...]: async def test_smoke_cohort(tmp_path: pathlib.Path) -> None: cohort_key = f"ci-smoke-{ENV}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}" - # ── Phase 1: submit the cohort (mixed worker slugs) ─────────────── run_ids = await submit_cohort( benchmark_slug=ENV, - slots=[(s.worker_slug, s.criterion_slug) for s in COHORT], + slots=[(WORKER, CRITERION)] * COHORT_SIZE, cohort_key=cohort_key, timeout=PER_RUN_TIMEOUT, ) - assert len(run_ids) == len(COHORT) - slotted: list[tuple[CohortSlot, uuid.UUID]] = list(zip(COHORT, run_ids)) + assert len(run_ids) == COHORT_SIZE - # ── Phase 2: wait for terminal state ────────────────────────────── await asyncio.gather( *( - wait_for_terminal(rid, timeout_seconds=PER_RUN_TIMEOUT) - if slot.kind == "happy" - else wait_for_terminal_status( + wait_for_terminal_status( rid, - expected_statuses=frozenset({"completed"}), + expected_statuses=frozenset({"failed"}), timeout_seconds=PER_RUN_TIMEOUT, ) - for slot, rid in slotted + for rid in run_ids ), ) - # ── Phase 3: per-run assertions (dispatched on kind) ────────────── - for slot, rid in slotted: - if slot.kind == "happy": - _assert_happy_run(rid) - else: - _assert_sad_run(rid) + for rid in run_ids: + _assert_sad_run(rid) - # ── Phase 3b: cohort-level invariant ────────────────────────────── _assert_cohort_membership(cohort_key, run_ids) - # ── Phase 4: Playwright subprocess (screenshots per run) ────────── screenshot_dir_env = os.environ.get("SCREENSHOT_DIR") screenshot_dir = ( pathlib.Path(screenshot_dir_env) @@ -131,71 +83,22 @@ async def test_smoke_cohort(tmp_path: pathlib.Path) -> None: ) _invoke_playwright( cohort_key=cohort_key, - cohort=[{"run_id": str(rid), "kind": s.kind} for s, rid in slotted], + cohort=[{"run_id": str(rid), "kind": "sad"} for rid in run_ids], screenshot_dir=screenshot_dir, ) - # Phase 5 (finalizer) — see tests/e2e/conftest.py ``_screenshot_uploader``. - -def _assert_happy_run(rid: uuid.UUID) -> None: - _assert_run_graph(rid) - _assert_run_resources(rid) - _assert_run_turn_counts(rid) - _assert_sandbox_command_wal(rid) - _assert_sandbox_lifecycle_events(rid) - _assert_thread_messages_ordered(rid) - _assert_blob_roundtrip(rid) - _assert_temporal_ordering(rid) - _assert_run_evaluation(rid) - # Env-specific content check is inside the criterion + also rerun here - # via _assert_env_content_happy below. - _assert_env_content_happy(rid) - - -def _assert_sad_run(rid: uuid.UUID) -> None: +def _assert_sad_run(rid) -> None: _assert_sadpath_graph_cascade(rid) _assert_sadpath_partial_artifact(rid) _assert_sadpath_partial_wal(rid) _assert_sadpath_thread_messages(rid) _assert_sadpath_evaluation(rid) - _assert_sandbox_command_wal(rid) _assert_sandbox_lifecycle_events(rid) + _assert_sandbox_command_wal(rid) _assert_temporal_ordering(rid) -def _assert_env_content_happy(rid: uuid.UUID) -> None: - """Out-of-band re-verification that each happy leaf produced a - well-formed ``report_*.md``. Duplicates what - ``ResearchRubricsSmokeCriterion._verify_env_content`` does inside - the workflow — if the criterion regresses silently, this catches it.""" - from pathlib import Path - - from sqlmodel import select - - from ergon_core.core.persistence.shared.db import get_session - from ergon_core.core.persistence.telemetry.models import RunResource - - with get_session() as s: - reports = list( - s.exec( - select(RunResource) - .where(RunResource.run_id == rid) - .where( - RunResource.name.like("report_%.md"), # ty: ignore[unresolved-attribute] - ) - .where(RunResource.kind == "report"), # blob-store only (host-accessible) - ).all(), - ) - assert len(reports) == 9, f"expected 9 reports, got {len(reports)}" - for r in reports: - body = Path(r.file_path).read_bytes() - assert body.startswith(b"# Research report"), ( - f"{r.name}: missing `# Research report` header" - ) - assert len(body.strip()) >= 20, f"{r.name}: body < 20 bytes" - - def _invoke_playwright( *, cohort_key: str, diff --git a/tests/e2e/test_swebench_smoke.py b/tests/e2e/test_swebench_smoke.py index ba08191c..9889d5f9 100644 --- a/tests/e2e/test_swebench_smoke.py +++ b/tests/e2e/test_swebench_smoke.py @@ -1,9 +1,4 @@ -"""SWE-Bench Verified canonical smoke — cohort of 3 happy runs against real E2B. - -No sad-path slot (researchrubrics leg carries that). Structure -identical to ``test_minif2f_smoke.py``; differs only in env slug and -spec filename. -""" +"""SWE-Bench Verified canonical sad-path smoke against real E2B.""" from __future__ import annotations @@ -12,24 +7,21 @@ import os import pathlib import subprocess -import uuid from datetime import datetime, timezone import pytest from tests.e2e._asserts import ( - _assert_blob_roundtrip, _assert_cohort_membership, - _assert_run_evaluation, - _assert_run_graph, - _assert_run_resources, - _assert_run_turn_counts, + _assert_sadpath_evaluation, + _assert_sadpath_graph_cascade, + _assert_sadpath_partial_artifact, + _assert_sadpath_partial_wal, + _assert_sadpath_thread_messages, _assert_sandbox_command_wal, _assert_sandbox_lifecycle_events, - _assert_swebench_artifacts, _assert_temporal_ordering, - _assert_thread_messages_ordered, - wait_for_terminal, + wait_for_terminal_status, ) from tests.e2e._submit import submit_cohort @@ -39,10 +31,10 @@ # maps 1:1 to the spec filename. ENV = "swebench-verified" WORKER_PREFIX = "swebench" -WORKER = f"{WORKER_PREFIX}-smoke-worker" +WORKER = f"{WORKER_PREFIX}-sadpath-smoke-worker" CRITERION = f"{WORKER_PREFIX}-smoke-criterion" -# ``SMOKE_COHORT_SIZE`` override for local dev; CI uses default 3. -COHORT_SIZE = int(os.environ.get("SMOKE_COHORT_SIZE", "3")) +# ``SMOKE_COHORT_SIZE`` override for local/dev deep checks; CI uses default 1. +COHORT_SIZE = int(os.environ.get("SMOKE_COHORT_SIZE", "1")) PER_RUN_TIMEOUT = 270 @@ -60,20 +52,25 @@ async def test_smoke_cohort(tmp_path: pathlib.Path) -> None: assert len(run_ids) == COHORT_SIZE await asyncio.gather( - *(wait_for_terminal(rid, timeout_seconds=PER_RUN_TIMEOUT) for rid in run_ids), + *( + wait_for_terminal_status( + rid, + expected_statuses=frozenset({"failed"}), + timeout_seconds=PER_RUN_TIMEOUT, + ) + for rid in run_ids + ), ) for rid in run_ids: - _assert_run_graph(rid) - _assert_run_resources(rid) - _assert_run_turn_counts(rid) - _assert_sandbox_command_wal(rid) + _assert_sadpath_graph_cascade(rid) + _assert_sadpath_partial_artifact(rid) + _assert_sadpath_partial_wal(rid) + _assert_sadpath_thread_messages(rid) + _assert_sadpath_evaluation(rid) _assert_sandbox_lifecycle_events(rid) - _assert_thread_messages_ordered(rid) - _assert_blob_roundtrip(rid) + _assert_sandbox_command_wal(rid) _assert_temporal_ordering(rid) - _assert_run_evaluation(rid) - _assert_swebench_artifacts(rid) _assert_cohort_membership(cohort_key, run_ids) @@ -85,7 +82,7 @@ async def test_smoke_cohort(tmp_path: pathlib.Path) -> None: ) _invoke_playwright( cohort_key=cohort_key, - cohort=[{"run_id": str(rid), "kind": "happy"} for rid in run_ids], + cohort=[{"run_id": str(rid), "kind": "sad"} for rid in run_ids], screenshot_dir=screenshot_dir, ) diff --git a/tests/real_llm/benchmarks/test_smoke_stub.py b/tests/real_llm/benchmarks/test_smoke_stub.py index e549c752..097fb557 100644 --- a/tests/real_llm/benchmarks/test_smoke_stub.py +++ b/tests/real_llm/benchmarks/test_smoke_stub.py @@ -49,7 +49,7 @@ async def test_harness_canary_smoke_stub( env = { **os.environ, "ENABLE_TEST_HARNESS": "1", - "ENABLE_SMOKE_FIXTURES": "1", + "ERGON_STARTUP_PLUGINS": "ergon_core.test_support.smoke_fixtures:register_smoke_fixtures", "ERGON_DATABASE_URL": os.environ.get( "ERGON_DATABASE_URL", "postgresql://ergon:ergon_dev@127.0.0.1:5433/ergon", diff --git a/tests/unit/architecture/test_no_test_logic_in_core.py b/tests/unit/architecture/test_no_test_logic_in_core.py new file mode 100644 index 00000000..1bde37d1 --- /dev/null +++ b/tests/unit/architecture/test_no_test_logic_in_core.py @@ -0,0 +1,60 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +CORE = ROOT / "ergon_core" / "ergon_core" / "core" + +ALLOWED_FILES = { + CORE / "api" / "test_harness.py", + CORE / "settings.py", +} + +FORBIDDEN_IMPORT_SNIPPETS = ( + "ergon_core.test_support", + "tests.", +) + +FORBIDDEN_CORE_TEST_DOUBLE_TERMS = ( + "StubSandboxManager", + "is_stub_sandbox_id", + "stub-sandbox-", +) + + +def _core_python_files() -> list[Path]: + return [ + path + for path in CORE.rglob("*.py") + if path not in ALLOWED_FILES and "__pycache__" not in path.parts + ] + + +def test_core_does_not_import_test_support_or_tests() -> None: + offenders: list[str] = [] + for path in _core_python_files(): + text = path.read_text() + for snippet in FORBIDDEN_IMPORT_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} contains {snippet!r}") + + assert offenders == [] + + +def test_core_does_not_define_or_branch_on_stub_sandbox_terms() -> None: + offenders: list[str] = [] + for path in _core_python_files(): + text = path.read_text() + for term in FORBIDDEN_CORE_TEST_DOUBLE_TERMS: + if term in text: + offenders.append(f"{path.relative_to(ROOT)} contains {term!r}") + + assert offenders == [] + + +def test_core_task_execution_does_not_mint_placeholder_sandbox_ids() -> None: + path = CORE / "runtime" / "inngest" / "execute_task.py" + text = path.read_text() + + assert "StubSandboxManager" not in text + assert "make_noop_sandbox_id" not in text + assert "stub_sandbox_id" not in text diff --git a/tests/unit/architecture/test_smoke_fixture_package_boundary.py b/tests/unit/architecture/test_smoke_fixture_package_boundary.py index 6f50f458..3817267b 100644 --- a/tests/unit/architecture/test_smoke_fixture_package_boundary.py +++ b/tests/unit/architecture/test_smoke_fixture_package_boundary.py @@ -13,7 +13,9 @@ def test_runtime_entrypoints_do_not_import_tests_smoke_fixtures() -> None: text = path.read_text() assert "tests.e2e._fixtures" not in text assert "ergon_core.dev.smoke_fixtures" not in text - assert "ergon_core.test_support.smoke_fixtures" in text + assert "ergon_core.test_support.smoke_fixtures" not in Path( + "ergon_core/ergon_core/core/api/app.py" + ).read_text() def test_smoke_fixtures_live_in_test_support_package() -> None: diff --git a/tests/unit/cli/test_eval_cli_required_fields.py b/tests/unit/cli/test_eval_cli_required_fields.py new file mode 100644 index 00000000..86098fa7 --- /dev/null +++ b/tests/unit/cli/test_eval_cli_required_fields.py @@ -0,0 +1,18 @@ +import pytest + +from ergon_cli.main import build_parser + + +@pytest.mark.parametrize("action", ["watch", "checkpoint"]) +def test_eval_commands_require_evaluator_and_model_base(action: str) -> None: + parser = build_parser() + args = ["eval", action] + if action == "watch": + args.extend(["--checkpoint-dir", "/tmp/checkpoints", "--benchmark", "minif2f"]) + else: + args.extend(["--checkpoint", "/tmp/checkpoints/checkpoint-1", "--benchmark", "minif2f"]) + + with pytest.raises(SystemExit) as exc_info: + parser.parse_args(args) + + assert exc_info.value.code == 2 diff --git a/tests/unit/dashboard/test_communication_threads.py b/tests/unit/dashboard/test_communication_threads.py new file mode 100644 index 00000000..5b7b0208 --- /dev/null +++ b/tests/unit/dashboard/test_communication_threads.py @@ -0,0 +1,83 @@ +from uuid import uuid4 + +from ergon_core.core.api.runs import _build_communication_threads +from ergon_core.core.persistence.telemetry.models import Thread, ThreadMessage + + +def test_build_communication_threads_populates_summary_and_task_anchors() -> None: + run_id = uuid4() + thread_id = uuid4() + execution_id = uuid4() + task_id = uuid4() + thread = Thread( + id=thread_id, + run_id=run_id, + topic="smoke-completion", + summary="Leaf workers report completion artifacts and probe exit status.", + agent_a_id="leaf-l_1", + agent_b_id="parent", + ) + message = ThreadMessage( + thread_id=thread_id, + run_id=run_id, + task_execution_id=execution_id, + from_agent_id="leaf-l_1", + to_agent_id="parent", + content="l_1: done exit=0", + sequence_num=1, + ) + + result = _build_communication_threads( + [thread], + [message], + {execution_id: task_id}, + ) + + assert len(result) == 1 + dto = result[0] + assert dto.summary == "Leaf workers report completion artifacts and probe exit status." + assert dto.task_id == str(task_id) + assert dto.messages[0].task_id == str(task_id) + assert dto.messages[0].task_execution_id == str(execution_id) + + +def test_build_communication_threads_keeps_run_level_thread_when_messages_span_tasks() -> None: + run_id = uuid4() + thread_id = uuid4() + execution_a = uuid4() + execution_b = uuid4() + thread = Thread( + id=thread_id, + run_id=run_id, + topic="smoke-completion", + agent_a_id="leaf-l_1", + agent_b_id="parent", + ) + messages = [ + ThreadMessage( + thread_id=thread_id, + run_id=run_id, + task_execution_id=execution_a, + from_agent_id="leaf-l_1", + to_agent_id="parent", + content="l_1: done exit=0", + sequence_num=1, + ), + ThreadMessage( + thread_id=thread_id, + run_id=run_id, + task_execution_id=execution_b, + from_agent_id="leaf-l_2", + to_agent_id="parent", + content="l_2: done exit=0", + sequence_num=2, + ), + ] + + result = _build_communication_threads( + [thread], + messages, + {execution_a: uuid4(), execution_b: uuid4()}, + ) + + assert result[0].task_id is None diff --git a/tests/unit/dashboard/test_event_contract_types.py b/tests/unit/dashboard/test_event_contract_types.py index 5311f0b3..e984decc 100644 --- a/tests/unit/dashboard/test_event_contract_types.py +++ b/tests/unit/dashboard/test_event_contract_types.py @@ -24,5 +24,11 @@ def test_thread_message_dto_exposes_execution_identity() -> None: assert "task_execution_id" in RunCommunicationMessageDto.model_fields +def test_thread_dto_exposes_summary_and_task_identity() -> None: + assert "summary" in RunCommunicationThreadDto.model_fields + assert "task_id" in RunCommunicationThreadDto.model_fields + assert "task_id" in RunCommunicationMessageDto.model_fields + + def test_cohort_updated_event_uses_cohort_summary_dto() -> None: assert CohortUpdatedEvent.model_fields["summary"].annotation is CohortSummaryDto diff --git a/tests/unit/runtime/test_communication_service.py b/tests/unit/runtime/test_communication_service.py new file mode 100644 index 00000000..abb294c3 --- /dev/null +++ b/tests/unit/runtime/test_communication_service.py @@ -0,0 +1,114 @@ +from collections.abc import Iterator +from uuid import uuid4 + +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +from ergon_core.core.runtime.services import communication_service as module +from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest + +Thread = module.Thread + + +@pytest.fixture() +def session_factory() -> Iterator[tuple[Session, object]]: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + + def _get_session() -> Session: + return Session(engine) + + yield _get_session + + +@pytest.mark.asyncio +async def test_save_message_persists_thread_summary_and_emits_it( + monkeypatch: pytest.MonkeyPatch, + session_factory, +) -> None: + emitted: list[tuple[object, object]] = [] + + async def _record_thread_event(*, run_id, thread, message) -> None: # noqa: ANN001 + emitted.append((thread, message)) + + monkeypatch.setattr(module, "get_session", session_factory) + monkeypatch.setattr(module.dashboard_emitter, "thread_message_created", _record_thread_event) + + run_id = uuid4() + execution_id = uuid4() + summary = "Leaf workers report completion artifacts and probe exit status." + + response = await module.CommunicationService().save_message( + CreateMessageRequest( + run_id=run_id, + from_agent_id="leaf-l_1", + to_agent_id="parent", + thread_topic="smoke-completion", + thread_summary=summary, + content="l_1: done exit=0", + task_execution_id=execution_id, + ) + ) + + with session_factory() as session: + thread = session.exec(select(Thread).where(Thread.id == response.thread_id)).one() + + assert thread.summary == summary + assert emitted + thread_dto, message_dto = emitted[0] + assert thread_dto.summary == summary + assert message_dto.task_execution_id == str(execution_id) + + +@pytest.mark.asyncio +async def test_save_message_backfills_missing_summary_without_overwriting_existing( + monkeypatch: pytest.MonkeyPatch, + session_factory, +) -> None: + async def _ignore_thread_event(*, run_id, thread, message) -> None: # noqa: ANN001 + return None + + monkeypatch.setattr(module, "get_session", session_factory) + monkeypatch.setattr(module.dashboard_emitter, "thread_message_created", _ignore_thread_event) + + service = module.CommunicationService() + run_id = uuid4() + await service.save_message( + CreateMessageRequest( + run_id=run_id, + from_agent_id="leaf-l_1", + to_agent_id="parent", + thread_topic="smoke-completion", + content="l_1: done exit=0", + ) + ) + await service.save_message( + CreateMessageRequest( + run_id=run_id, + from_agent_id="leaf-l_2", + to_agent_id="parent", + thread_topic="smoke-completion", + thread_summary="Completion reports from leaf workers.", + content="l_2: done exit=0", + ) + ) + await service.save_message( + CreateMessageRequest( + run_id=run_id, + from_agent_id="leaf-l_3", + to_agent_id="parent", + thread_topic="smoke-completion", + thread_summary="Replacement summary should not win.", + content="l_3: done exit=0", + ) + ) + + with session_factory() as session: + thread = session.exec(select(Thread).where(Thread.run_id == run_id)).one() + + assert thread.summary == "Completion reports from leaf workers." diff --git a/tests/unit/runtime/test_failed_task_sandbox_cleanup.py b/tests/unit/runtime/test_failed_task_sandbox_cleanup.py new file mode 100644 index 00000000..1fb202c9 --- /dev/null +++ b/tests/unit/runtime/test_failed_task_sandbox_cleanup.py @@ -0,0 +1,25 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from ergon_core.core.providers.sandbox.lifecycle import ( + SandboxTerminationReason, + SandboxTerminationResult, +) +from ergon_core.core.runtime.inngest.propagate_execution import _terminate_failed_task_sandbox + + +@pytest.mark.asyncio +async def test_failed_task_sandbox_cleanup_delegates_to_lifecycle_service() -> None: + result = SandboxTerminationResult( + sandbox_id="sandbox-real", + terminated=True, + reason=SandboxTerminationReason.TERMINATED, + ) + with patch( + "ergon_core.core.runtime.inngest.propagate_execution.terminate_sandbox_by_id", + new=AsyncMock(return_value=result), + ) as terminate: + await _terminate_failed_task_sandbox("sandbox-real") + + terminate.assert_awaited_once_with("sandbox-real") diff --git a/tests/unit/runtime/test_worker_execute_output_failure.py b/tests/unit/runtime/test_worker_execute_output_failure.py new file mode 100644 index 00000000..6ec0ccf2 --- /dev/null +++ b/tests/unit/runtime/test_worker_execute_output_failure.py @@ -0,0 +1,13 @@ +from ergon_core.api.results import WorkerOutput +from ergon_core.core.runtime.inngest.worker_execute import _worker_execute_result_from_output + + +def test_worker_execute_result_preserves_worker_output_failure() -> None: + result = _worker_execute_result_from_output( + WorkerOutput(output="probe failed", success=False), + ) + + assert result.success is False + assert result.final_assistant_message == "probe failed" + assert result.error == "probe failed" + diff --git a/tests/unit/sandbox/test_sandbox_lifecycle_service.py b/tests/unit/sandbox/test_sandbox_lifecycle_service.py new file mode 100644 index 00000000..2753ab48 --- /dev/null +++ b/tests/unit/sandbox/test_sandbox_lifecycle_service.py @@ -0,0 +1,30 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from ergon_core.core.providers.sandbox.lifecycle import ( + SandboxTerminationReason, + terminate_sandbox_by_id, +) + + +@pytest.mark.asyncio +async def test_terminate_sandbox_by_id_dispatches_real_ids() -> None: + with patch( + "ergon_core.core.providers.sandbox.manager.BaseSandboxManager.terminate_by_sandbox_id", + new=AsyncMock(return_value=True), + ) as terminate: + result = await terminate_sandbox_by_id("sbx-live-123") + + terminate.assert_awaited_once_with("sbx-live-123") + assert result.terminated is True + assert result.reason == SandboxTerminationReason.TERMINATED + + +@pytest.mark.asyncio +async def test_terminate_sandbox_by_id_handles_missing_id_explicitly() -> None: + result = await terminate_sandbox_by_id(None) + + assert result.terminated is False + assert result.reason == SandboxTerminationReason.MISSING_ID + assert result.sandbox_id is None diff --git a/tests/unit/sandbox/test_stub_sandbox_id.py b/tests/unit/sandbox/test_stub_sandbox_id.py index 47b8dc4a..daed92c1 100644 --- a/tests/unit/sandbox/test_stub_sandbox_id.py +++ b/tests/unit/sandbox/test_stub_sandbox_id.py @@ -1,7 +1,7 @@ -"""Tests for is_stub_sandbox_id() sentinel check.""" +"""Tests for test-support stub sandbox IDs.""" import pytest -from ergon_core.core.providers.sandbox.manager import is_stub_sandbox_id +from ergon_core.test_support.sandbox import is_stub_sandbox_id @pytest.mark.parametrize( diff --git a/tests/unit/smoke_base/test_leaf_sends_completion_message.py b/tests/unit/smoke_base/test_leaf_sends_completion_message.py index 5827c0bb..22865dac 100644 --- a/tests/unit/smoke_base/test_leaf_sends_completion_message.py +++ b/tests/unit/smoke_base/test_leaf_sends_completion_message.py @@ -102,6 +102,7 @@ async def _record(request: CreateMessageRequest) -> MagicMock: assert req.from_agent_id == "leaf-l_2" assert req.to_agent_id == "parent" assert req.thread_topic == "smoke-completion" + assert req.thread_summary is None assert "l_2" in req.content assert "exit=0" in req.content diff --git a/tests/unit/smoke_base/test_registry_smoke_entries.py b/tests/unit/smoke_base/test_registry_smoke_entries.py index e09069b2..893b09ea 100644 --- a/tests/unit/smoke_base/test_registry_smoke_entries.py +++ b/tests/unit/smoke_base/test_registry_smoke_entries.py @@ -54,6 +54,8 @@ def test_minif2f_slugs_registered() -> None: assert "minif2f-smoke-worker" in WORKERS assert "minif2f-smoke-leaf" in WORKERS + assert "minif2f-sadpath-smoke-worker" in WORKERS + assert "minif2f-smoke-leaf-failing" in WORKERS assert "minif2f-smoke-criterion" in EVALUATORS @@ -65,6 +67,8 @@ def test_swebench_slugs_registered() -> None: assert "swebench-smoke-worker" in WORKERS assert "swebench-smoke-leaf" in WORKERS + assert "swebench-sadpath-smoke-worker" in WORKERS + assert "swebench-smoke-leaf-failing" in WORKERS assert "swebench-smoke-criterion" in EVALUATORS diff --git a/tests/unit/smoke_base/test_sadpath_worker_routing.py b/tests/unit/smoke_base/test_sadpath_worker_routing.py index fa416e2f..9cad3fe5 100644 --- a/tests/unit/smoke_base/test_sadpath_worker_routing.py +++ b/tests/unit/smoke_base/test_sadpath_worker_routing.py @@ -7,41 +7,56 @@ from uuid import uuid4 from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug -from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke_sadpath import ( +import pytest + +from ergon_core.test_support.smoke_fixtures.workers.minif2f_smoke import ( + MiniF2FSadPathSmokeWorker, +) +from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke import ( ResearchRubricsSadPathSmokeWorker, ) +from ergon_core.test_support.smoke_fixtures.workers.swebench_smoke import ( + SweBenchSadPathSmokeWorker, +) -def _worker() -> ResearchRubricsSadPathSmokeWorker: - return ResearchRubricsSadPathSmokeWorker( +@pytest.mark.parametrize( + ("worker_cls", "happy_leaf", "failing_leaf"), + [ + ( + ResearchRubricsSadPathSmokeWorker, + "researchrubrics-smoke-leaf", + "researchrubrics-smoke-leaf-failing", + ), + (MiniF2FSadPathSmokeWorker, "minif2f-smoke-leaf", "minif2f-smoke-leaf-failing"), + (SweBenchSadPathSmokeWorker, "swebench-smoke-leaf", "swebench-smoke-leaf-failing"), + ], +) +def test_l_2_routed_to_failing_leaf(worker_cls, happy_leaf: str, failing_leaf: str) -> None: + worker = worker_cls( name="unit-test", model=None, task_id=uuid4(), sandbox_id="sbx-unit", ) - - -def test_l_2_routed_to_failing_leaf() -> None: - worker = _worker() spec = worker._spec_for("l_2", ("l_1",), "Line 2") assert spec.task_slug == TaskSlug("l_2") - assert spec.assigned_worker_slug == AssignedWorkerSlug( - "researchrubrics-smoke-leaf-failing", - ) + assert spec.assigned_worker_slug == AssignedWorkerSlug(failing_leaf) assert spec.depends_on == [TaskSlug("l_1")] - -def test_all_other_slugs_use_happy_leaf() -> None: - worker = _worker() for slug in ("d_root", "d_left", "d_right", "d_join", "l_1", "l_3", "s_a", "s_b"): spec = worker._spec_for(slug, (), "…") - assert spec.assigned_worker_slug == AssignedWorkerSlug( - "researchrubrics-smoke-leaf", - ), f"{slug} should use happy leaf, got {spec.assigned_worker_slug}" + assert spec.assigned_worker_slug == AssignedWorkerSlug(happy_leaf), ( + f"{slug} should use happy leaf, got {spec.assigned_worker_slug}" + ) -def test_only_l_2_is_in_failing_slugs() -> None: +@pytest.mark.parametrize( + "worker_cls", + [ResearchRubricsSadPathSmokeWorker, MiniF2FSadPathSmokeWorker, SweBenchSadPathSmokeWorker], +) +def test_only_l_2_is_in_failing_slugs(worker_cls) -> None: """Sanity: future additions to FAILING_SLUGS should be conscious. If this assertion tightens, the sad-path driver's invariants must be updated in lock-step (8 messages vs 7, partial count, etc.).""" - assert ResearchRubricsSadPathSmokeWorker.FAILING_SLUGS == frozenset({"l_2"}) + assert worker_cls.FAILING_SLUGS == frozenset({"l_2"}) diff --git a/tests/unit/smoke_base/test_smoke_sandbox_manager.py b/tests/unit/smoke_base/test_smoke_sandbox_manager.py index 8e0692fe..661cceb7 100644 --- a/tests/unit/smoke_base/test_smoke_sandbox_manager.py +++ b/tests/unit/smoke_base/test_smoke_sandbox_manager.py @@ -1,3 +1,4 @@ +from pathlib import Path from uuid import UUID, uuid4 import pytest @@ -94,11 +95,16 @@ async def test_static_teardown_closes_registered_smoke_sandbox() -> None: try: sandbox_id = await manager.create(task_id, run_id=run_id) + tempdir = SmokeSandboxManager._tempdirs[task_id] + tempdir_path = Path(tempdir.name) terminated = await BaseSandboxManager.terminate_by_sandbox_id(sandbox_id) assert terminated is True assert manager.get_sandbox(task_id) is None + assert sandbox_id not in SmokeSandboxManager._sandbox_ids + assert task_id not in SmokeSandboxManager._tempdirs + assert not tempdir_path.exists() assert sink.closed == [(str(run_id), sandbox_id)] finally: SmokeSandboxManager.set_event_sink(NoopSandboxEventSink()) diff --git a/tests/unit/test_app_mounts_harness_conditionally.py b/tests/unit/test_app_mounts_harness_conditionally.py index 8310bdf9..dd7ce8f4 100644 --- a/tests/unit/test_app_mounts_harness_conditionally.py +++ b/tests/unit/test_app_mounts_harness_conditionally.py @@ -8,6 +8,7 @@ def _reload_app_with(monkeypatch: pytest.MonkeyPatch, env_value: str | None): + monkeypatch.delenv("ERGON_STARTUP_PLUGINS", raising=False) if env_value is None: monkeypatch.delenv("ENABLE_TEST_HARNESS", raising=False) else: diff --git a/tests/unit/test_test_harness.py b/tests/unit/test_test_harness.py index b967e2da..3f34038d 100644 --- a/tests/unit/test_test_harness.py +++ b/tests/unit/test_test_harness.py @@ -8,6 +8,7 @@ from fastapi.testclient import TestClient from ergon_core.core.api import test_harness +from ergon_core.core.api.startup_plugins import run_startup_plugins from ergon_core.core.api.test_harness import get_session_dep, router @@ -108,3 +109,8 @@ def test_reset_requires_secret_header(monkeypatch: pytest.MonkeyPatch) -> None: client = TestClient(app) resp = client.post("/api/test/write/reset", json={"cohort_prefix": "ci-smoke-"}) assert resp.status_code == 401 + + +def test_startup_plugin_loader_rejects_invalid_specs() -> None: + with pytest.raises(RuntimeError, match="expected 'module:function'"): + run_startup_plugins(("ergon_core.test_support.smoke_fixtures",)) From 8589b8300b3cd2d7b3345edf3358d9ea681b0560 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:32:23 +0100 Subject: [PATCH 04/66] Fix CI formatting and generated contracts Apply Ruff formatting and regenerate dashboard contracts so the Python and frontend drift checks agree with the committed sources. Made-with: Cursor --- .../DashboardThreadMessageCreatedEvent.schema.json | 12 ++++++++++++ ergon-dashboard/src/generated/rest/contracts.ts | 3 --- ergon_core/ergon_core/core/api/runs.py | 3 +-- ergon_core/ergon_core/core/api/startup_plugins.py | 3 +-- ergon_core/ergon_core/core/settings.py | 6 +----- tests/e2e/_asserts.py | 4 +++- .../test_smoke_fixture_package_boundary.py | 7 ++++--- .../runtime/test_worker_execute_output_failure.py | 1 - 8 files changed, 22 insertions(+), 17 deletions(-) diff --git a/ergon-dashboard/src/generated/events/schemas/DashboardThreadMessageCreatedEvent.schema.json b/ergon-dashboard/src/generated/events/schemas/DashboardThreadMessageCreatedEvent.schema.json index 24d9b177..0717b7c1 100644 --- a/ergon-dashboard/src/generated/events/schemas/DashboardThreadMessageCreatedEvent.schema.json +++ b/ergon-dashboard/src/generated/events/schemas/DashboardThreadMessageCreatedEvent.schema.json @@ -106,6 +106,18 @@ "title": "Topic", "type": "string" }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Summary" + }, "agentAId": { "title": "Agentaid", "type": "string" diff --git a/ergon-dashboard/src/generated/rest/contracts.ts b/ergon-dashboard/src/generated/rest/contracts.ts index b641a5d6..fee60197 100644 --- a/ergon-dashboard/src/generated/rest/contracts.ts +++ b/ergon-dashboard/src/generated/rest/contracts.ts @@ -119,7 +119,6 @@ const RunCommunicationThreadDto = z.object({ runId: z.string(), taskId: z.union([z.string(), z.null()]).optional(), topic: z.string(), - summary: z.union([z.string(), z.null()]).optional(), agentAId: z.string(), agentBId: z.string(), createdAt: z.string().datetime({ offset: true }), @@ -234,8 +233,6 @@ const CohortRunRowDto = z completed_at: z.union([z.string(), z.null()]).optional(), running_time_ms: z.union([z.number(), z.null()]).optional(), final_score: z.union([z.number(), z.null()]).optional(), - total_tasks: z.union([z.number().int(), z.null()]).optional(), - total_cost_usd: z.union([z.number(), z.null()]).optional(), error_message: z.union([z.string(), z.null()]).optional(), }) .passthrough(); diff --git a/ergon_core/ergon_core/core/api/runs.py b/ergon_core/ergon_core/core/api/runs.py index 38a46b67..6bf766e4 100644 --- a/ergon_core/ergon_core/core/api/runs.py +++ b/ergon_core/ergon_core/core/api/runs.py @@ -325,8 +325,7 @@ def _build_communication_threads( thread_topic=t.topic, task_id=( str(execution_task_map[m.task_execution_id]) - if m.task_execution_id - and m.task_execution_id in execution_task_map + if m.task_execution_id and m.task_execution_id in execution_task_map else None ), task_execution_id=str(m.task_execution_id) if m.task_execution_id else None, diff --git a/ergon_core/ergon_core/core/api/startup_plugins.py b/ergon_core/ergon_core/core/api/startup_plugins.py index 3573d910..ba594f0a 100644 --- a/ergon_core/ergon_core/core/api/startup_plugins.py +++ b/ergon_core/ergon_core/core/api/startup_plugins.py @@ -8,8 +8,7 @@ def run_startup_plugins(plugin_specs: tuple[str, ...]) -> None: module_name, sep, attr_name = spec.partition(":") if not sep or not module_name or not attr_name: raise RuntimeError( - "Invalid ERGON_STARTUP_PLUGINS entry " - f"{spec!r}; expected 'module:function'" + f"Invalid ERGON_STARTUP_PLUGINS entry {spec!r}; expected 'module:function'" ) module = import_module(module_name) plugin = getattr(module, attr_name) diff --git a/ergon_core/ergon_core/core/settings.py b/ergon_core/ergon_core/core/settings.py index 272d0f99..e2643d71 100644 --- a/ergon_core/ergon_core/core/settings.py +++ b/ergon_core/ergon_core/core/settings.py @@ -71,11 +71,7 @@ def runs_dir(self) -> Path: @property def startup_plugins(self) -> tuple[str, ...]: - return tuple( - spec.strip() - for spec in self.startup_plugin_specs.split(",") - if spec.strip() - ) + return tuple(spec.strip() for spec in self.startup_plugin_specs.split(",") if spec.strip()) def missing_values(self, names: list[str]) -> list[str]: return [ diff --git a/tests/e2e/_asserts.py b/tests/e2e/_asserts.py index 44bb8484..09409faf 100644 --- a/tests/e2e/_asserts.py +++ b/tests/e2e/_asserts.py @@ -448,7 +448,9 @@ def _assert_sadpath_thread_messages(run_id: UUID) -> None: ) assert thread is not None, "no smoke-completion thread created" msgs = sorted(thread.messages, key=lambda msg: msg.sequence_num) - assert len(msgs) == 7, f"expected 7 completion messages (l_2 failed, l_3 blocked), got {len(msgs)}" + assert len(msgs) == 7, ( + f"expected 7 completion messages (l_2 failed, l_3 blocked), got {len(msgs)}" + ) from_slugs = {m.from_agent_id.removeprefix("leaf-") for m in msgs} assert "l_2" not in from_slugs, ( f"l_2 sent a completion message despite suppression: {from_slugs}" diff --git a/tests/unit/architecture/test_smoke_fixture_package_boundary.py b/tests/unit/architecture/test_smoke_fixture_package_boundary.py index 3817267b..f8542ee8 100644 --- a/tests/unit/architecture/test_smoke_fixture_package_boundary.py +++ b/tests/unit/architecture/test_smoke_fixture_package_boundary.py @@ -13,9 +13,10 @@ def test_runtime_entrypoints_do_not_import_tests_smoke_fixtures() -> None: text = path.read_text() assert "tests.e2e._fixtures" not in text assert "ergon_core.dev.smoke_fixtures" not in text - assert "ergon_core.test_support.smoke_fixtures" not in Path( - "ergon_core/ergon_core/core/api/app.py" - ).read_text() + assert ( + "ergon_core.test_support.smoke_fixtures" + not in Path("ergon_core/ergon_core/core/api/app.py").read_text() + ) def test_smoke_fixtures_live_in_test_support_package() -> None: diff --git a/tests/unit/runtime/test_worker_execute_output_failure.py b/tests/unit/runtime/test_worker_execute_output_failure.py index 6ec0ccf2..f421a542 100644 --- a/tests/unit/runtime/test_worker_execute_output_failure.py +++ b/tests/unit/runtime/test_worker_execute_output_failure.py @@ -10,4 +10,3 @@ def test_worker_execute_result_preserves_worker_output_failure() -> None: assert result.success is False assert result.final_assistant_message == "probe failed" assert result.error == "probe failed" - From 070e30f29b438ff6bd0d5a5d370532d425ffe233 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:39:02 +0100 Subject: [PATCH 05/66] Fix type checks after main sync Regenerate REST OpenAPI contracts, carry cancelled task counts through dashboard state, and clean up Python suppression/type-check issues from the sandbox boundary refactor. Made-with: Cursor --- .../features/activity/goldenFixture.test.ts | 1 + .../graph/contracts/graphMutations.test.ts | 1 + .../graph/state/graphMutationReducer.ts | 1 + .../src/generated/rest/contracts.ts | 140 ++++ .../src/generated/rest/openapi.json | 625 +++++++++++++++++- ergon-dashboard/src/lib/runState.ts | 1 + ergon-dashboard/src/lib/state/store.ts | 1 + ergon-dashboard/src/lib/types.ts | 1 + .../fixtures/mas-runs/concurrent-mas-run.json | 1 + .../tests/helpers/dashboardFixtures.ts | 1 + .../ergon_core/core/api/startup_plugins.py | 2 +- .../core/providers/sandbox/event_sink.py | 15 +- .../core/providers/sandbox/lifecycle.py | 7 +- .../test_support/sandbox/__init__.py | 9 +- .../test_support/sandbox/sentinel.py | 4 +- .../test_support/sandbox/stub_manager.py | 5 +- .../smoke_fixtures/smoke_base/worker_base.py | 2 +- .../runtime/test_communication_service.py | 4 +- 18 files changed, 804 insertions(+), 17 deletions(-) diff --git a/ergon-dashboard/src/features/activity/goldenFixture.test.ts b/ergon-dashboard/src/features/activity/goldenFixture.test.ts index 14dbc631..c8eecbe6 100644 --- a/ergon-dashboard/src/features/activity/goldenFixture.test.ts +++ b/ergon-dashboard/src/features/activity/goldenFixture.test.ts @@ -25,6 +25,7 @@ function emptyRunStateFrom(runState: WorkflowRunState): WorkflowRunState { completedTasks: 0, runningTasks: 0, failedTasks: 0, + cancelledTasks: 0, edges: new Map(), annotationsByTarget: new Map(), unhandledMutations: [], diff --git a/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts b/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts index a003c899..8225a7e1 100644 --- a/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts +++ b/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts @@ -37,6 +37,7 @@ function emptyState(): WorkflowRunState { completedTasks: 0, runningTasks: 0, failedTasks: 0, + cancelledTasks: 0, finalScore: null, error: null, edges: new Map(), diff --git a/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts b/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts index 0040afca..a705dcb2 100644 --- a/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts +++ b/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts @@ -545,6 +545,7 @@ export function createReplayInitialState( completedTasks: countStatus(tasks, TaskStatus.COMPLETED), runningTasks: countStatus(tasks, TaskStatus.RUNNING), failedTasks: countStatus(tasks, TaskStatus.FAILED), + cancelledTasks: countStatus(tasks, TaskStatus.CANCELLED), edges: new Map(), annotationsByTarget: new Map(), unhandledMutations: [], diff --git a/ergon-dashboard/src/generated/rest/contracts.ts b/ergon-dashboard/src/generated/rest/contracts.ts index fee60197..615345e6 100644 --- a/ergon-dashboard/src/generated/rest/contracts.ts +++ b/ergon-dashboard/src/generated/rest/contracts.ts @@ -1,5 +1,12 @@ import { z } from "zod"; +type JsonValue = + | (JsonScalar | Array | {}) + | Array | {}>; +type JsonScalar = + | (string | number | number | boolean | null) + | Array; + const RunTaskDto = z.object({ id: z.string(), name: z.string(), @@ -119,6 +126,7 @@ const RunCommunicationThreadDto = z.object({ runId: z.string(), taskId: z.union([z.string(), z.null()]).optional(), topic: z.string(), + summary: z.union([z.string(), z.null()]).optional(), agentAId: z.string(), agentBId: z.string(), createdAt: z.string().datetime({ offset: true }), @@ -146,6 +154,7 @@ const RunSnapshotDto = z.object({ completedTasks: z.number().int().optional().default(0), failedTasks: z.number().int().optional().default(0), runningTasks: z.number().int().optional().default(0), + cancelledTasks: z.number().int().optional().default(0), finalScore: z.union([z.number(), z.null()]).optional(), error: z.union([z.string(), z.null()]).optional(), }); @@ -162,6 +171,122 @@ const HTTPValidationError = z .object({ detail: z.array(ValidationError) }) .partial() .passthrough(); +const NodeAddedMutation = z + .object({ + mutation_type: z.string().optional().default("node.added"), + task_slug: z.string(), + instance_key: z.string(), + description: z.string(), + status: z.string(), + assigned_worker_slug: z.union([z.string(), z.null()]), + }) + .passthrough(); +const NodeRemovedMutation = z + .object({ + mutation_type: z.string().optional().default("node.removed"), + task_slug: z.string(), + instance_key: z.string(), + description: z.string(), + status: z.string(), + assigned_worker_slug: z.union([z.string(), z.null()]), + }) + .passthrough(); +const NodeStatusChangedMutation = z + .object({ + mutation_type: z.string().optional().default("node.status_changed"), + status: z.string(), + }) + .passthrough(); +const NodeFieldChangedMutation = z + .object({ + mutation_type: z.string().optional().default("node.field_changed"), + field: z.enum(["description", "assigned_worker_slug"]), + value: z.union([z.string(), z.null()]), + }) + .passthrough(); +const EdgeAddedMutation = z + .object({ + mutation_type: z.string().optional().default("edge.added"), + source_node_id: z.string(), + target_node_id: z.string(), + status: z.string(), + }) + .passthrough(); +const EdgeRemovedMutation = z + .object({ + mutation_type: z.string().optional().default("edge.removed"), + source_node_id: z.string(), + target_node_id: z.string(), + status: z.string(), + }) + .passthrough(); +const EdgeStatusChangedMutation = z + .object({ + mutation_type: z.string().optional().default("edge.status_changed"), + status: z.string(), + }) + .passthrough(); +const JsonScalar = z.union([ + z.string(), + z.number(), + z.number(), + z.boolean(), + z.null(), +]); +const JsonValue: z.ZodType = z.lazy(() => + z.union([JsonScalar, z.array(JsonValue), z.record(z.string(), JsonValue)]) +); +const JsonObject = z.record(z.string(), JsonValue); +const AnnotationSetMutation = z + .object({ + mutation_type: z.string().optional().default("annotation.set"), + namespace: z.string(), + payload: JsonObject, + }) + .passthrough(); +const AnnotationDeletedMutation = z + .object({ + mutation_type: z.string().optional().default("annotation.deleted"), + namespace: z.string(), + payload: JsonObject, + }) + .passthrough(); +const RunGraphMutationDto = z.object({ + id: z.string(), + run_id: z.string(), + sequence: z.number().int(), + mutation_type: z.string(), + target_type: z.string(), + target_id: z.string(), + actor: z.string(), + old_value: z.union([ + z.discriminatedUnion("mutation_type", [ + NodeAddedMutation, + NodeRemovedMutation, + NodeStatusChangedMutation, + NodeFieldChangedMutation, + EdgeAddedMutation, + EdgeRemovedMutation, + EdgeStatusChangedMutation, + AnnotationSetMutation, + AnnotationDeletedMutation, + ]), + z.null(), + ]), + new_value: z.discriminatedUnion("mutation_type", [ + NodeAddedMutation, + NodeRemovedMutation, + NodeStatusChangedMutation, + NodeFieldChangedMutation, + EdgeAddedMutation, + EdgeRemovedMutation, + EdgeStatusChangedMutation, + AnnotationSetMutation, + AnnotationDeletedMutation, + ]), + reason: z.union([z.string(), z.null()]), + created_at: z.string(), +}); const definition_id = z.union([z.string(), z.null()]).optional(); const TrainingCurvePointDto = z.object({ runId: z.string(), @@ -233,6 +358,8 @@ const CohortRunRowDto = z completed_at: z.union([z.string(), z.null()]).optional(), running_time_ms: z.union([z.number(), z.null()]).optional(), final_score: z.union([z.number(), z.null()]).optional(), + total_tasks: z.union([z.number(), z.null()]).optional(), + total_cost_usd: z.union([z.number(), z.null()]).optional(), error_message: z.union([z.string(), z.null()]).optional(), }) .passthrough(); @@ -314,6 +441,19 @@ export const schemas = { RunSnapshotDto, ValidationError, HTTPValidationError, + NodeAddedMutation, + NodeRemovedMutation, + NodeStatusChangedMutation, + NodeFieldChangedMutation, + EdgeAddedMutation, + EdgeRemovedMutation, + EdgeStatusChangedMutation, + JsonScalar, + JsonValue, + JsonObject, + AnnotationSetMutation, + AnnotationDeletedMutation, + RunGraphMutationDto, definition_id, TrainingCurvePointDto, TrainingSessionDto, diff --git a/ergon-dashboard/src/generated/rest/openapi.json b/ergon-dashboard/src/generated/rest/openapi.json index d04cfac1..906046ed 100644 --- a/ergon-dashboard/src/generated/rest/openapi.json +++ b/ergon-dashboard/src/generated/rest/openapi.json @@ -50,6 +50,106 @@ } } }, + "/runs/{run_id}/mutations": { + "get": { + "tags": [ + "runs" + ], + "summary": "Get Mutations", + "description": "Return the append-only mutation log for a run, ordered by sequence.\n\nUsed by the Timeline scrubber to replay DAG state at any point in time.", + "operationId": "get_mutations_runs__run_id__mutations_get", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RunGraphMutationDto" + }, + "title": "Response Get Mutations Runs Run Id Mutations Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/runs/{run_id}/resources/{resource_id}/content": { + "get": { + "tags": [ + "runs" + ], + "summary": "Get Resource Content", + "description": "Stream the blob bytes for a RunResource.\n\nUsed by the dashboard's file-viewer modal. Enforces:\n- resource must belong to the named run (no cross-run leaks);\n- resolved path must sit under ``ERGON_BLOB_ROOT`` (traversal guard);\n- size <= ``_RESOURCE_CONTENT_MAX_BYTES`` (413 otherwise).", + "operationId": "get_resource_content_runs__run_id__resources__resource_id__content_get", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Run Id" + } + }, + { + "name": "resource_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Resource Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/runs/training/curves": { "get": { "tags": [ @@ -579,6 +679,54 @@ }, "components": { "schemas": { + "AnnotationDeletedMutation": { + "properties": { + "mutation_type": { + "type": "string", + "const": "annotation.deleted", + "title": "Mutation Type", + "default": "annotation.deleted" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "payload": { + "$ref": "#/components/schemas/JsonObject" + } + }, + "type": "object", + "required": [ + "namespace", + "payload" + ], + "title": "AnnotationDeletedMutation", + "description": "annotation.deleted \u2014 tombstone." + }, + "AnnotationSetMutation": { + "properties": { + "mutation_type": { + "type": "string", + "const": "annotation.set", + "title": "Mutation Type", + "default": "annotation.set" + }, + "namespace": { + "type": "string", + "title": "Namespace" + }, + "payload": { + "$ref": "#/components/schemas/JsonObject" + } + }, + "type": "object", + "required": [ + "namespace", + "payload" + ], + "title": "AnnotationSetMutation", + "description": "annotation.set." + }, "BatchStatus": { "type": "string", "enum": [ @@ -686,6 +834,28 @@ ], "title": "Final Score" }, + "total_tasks": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total Tasks" + }, + "total_cost_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Total Cost Usd" + }, "error_message": { "anyOf": [ { @@ -864,6 +1034,86 @@ "title": "CohortSummaryDto", "description": "Summary row for cohort list and live updates." }, + "EdgeAddedMutation": { + "properties": { + "mutation_type": { + "type": "string", + "const": "edge.added", + "title": "Mutation Type", + "default": "edge.added" + }, + "source_node_id": { + "type": "string", + "title": "Source Node Id" + }, + "target_node_id": { + "type": "string", + "title": "Target Node Id" + }, + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "source_node_id", + "target_node_id", + "status" + ], + "title": "EdgeAddedMutation", + "description": "edge.added \u2014 full edge snapshot." + }, + "EdgeRemovedMutation": { + "properties": { + "mutation_type": { + "type": "string", + "const": "edge.removed", + "title": "Mutation Type", + "default": "edge.removed" + }, + "source_node_id": { + "type": "string", + "title": "Source Node Id" + }, + "target_node_id": { + "type": "string", + "title": "Target Node Id" + }, + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "source_node_id", + "target_node_id", + "status" + ], + "title": "EdgeRemovedMutation", + "description": "edge.removed." + }, + "EdgeStatusChangedMutation": { + "properties": { + "mutation_type": { + "type": "string", + "const": "edge.status_changed", + "title": "Mutation Type", + "default": "edge.status_changed" + }, + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "EdgeStatusChangedMutation", + "description": "edge.status_changed." + }, "EpisodeFailure": { "properties": { "run_id": { @@ -905,6 +1155,200 @@ "type": "object", "title": "HTTPValidationError" }, + "JsonObject": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + }, + "JsonScalar": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "JsonValue": { + "anyOf": [ + { + "$ref": "#/components/schemas/JsonScalar" + }, + { + "items": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object" + } + ] + }, + "NodeAddedMutation": { + "properties": { + "mutation_type": { + "type": "string", + "const": "node.added", + "title": "Mutation Type", + "default": "node.added" + }, + "task_slug": { + "type": "string", + "title": "Task Slug" + }, + "instance_key": { + "type": "string", + "title": "Instance Key" + }, + "description": { + "type": "string", + "title": "Description" + }, + "status": { + "type": "string", + "title": "Status" + }, + "assigned_worker_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assigned Worker Slug" + } + }, + "type": "object", + "required": [ + "task_slug", + "instance_key", + "description", + "status", + "assigned_worker_slug" + ], + "title": "NodeAddedMutation", + "description": "node.added \u2014 full node snapshot." + }, + "NodeFieldChangedMutation": { + "properties": { + "mutation_type": { + "type": "string", + "const": "node.field_changed", + "title": "Mutation Type", + "default": "node.field_changed" + }, + "field": { + "type": "string", + "enum": [ + "description", + "assigned_worker_slug" + ], + "title": "Field" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "type": "object", + "required": [ + "field", + "value" + ], + "title": "NodeFieldChangedMutation", + "description": "node.field_changed." + }, + "NodeRemovedMutation": { + "properties": { + "mutation_type": { + "type": "string", + "const": "node.removed", + "title": "Mutation Type", + "default": "node.removed" + }, + "task_slug": { + "type": "string", + "title": "Task Slug" + }, + "instance_key": { + "type": "string", + "title": "Instance Key" + }, + "description": { + "type": "string", + "title": "Description" + }, + "status": { + "type": "string", + "title": "Status" + }, + "assigned_worker_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assigned Worker Slug" + } + }, + "type": "object", + "required": [ + "task_slug", + "instance_key", + "description", + "status", + "assigned_worker_slug" + ], + "title": "NodeRemovedMutation", + "description": "node.removed \u2014 node snapshot at removal time." + }, + "NodeStatusChangedMutation": { + "properties": { + "mutation_type": { + "type": "string", + "const": "node.status_changed", + "title": "Mutation Type", + "default": "node.status_changed" + }, + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "NodeStatusChangedMutation", + "description": "node.status_changed." + }, "PollResponse": { "properties": { "batch_id": { @@ -1050,6 +1494,17 @@ "type": "string", "title": "Topic" }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Summary" + }, "agentAId": { "type": "string", "title": "Agentaid" @@ -1385,6 +1840,169 @@ ], "title": "RunExecutionAttemptDto" }, + "RunGraphMutationDto": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "run_id": { + "type": "string", + "title": "Run Id" + }, + "sequence": { + "type": "integer", + "title": "Sequence" + }, + "mutation_type": { + "type": "string", + "title": "Mutation Type" + }, + "target_type": { + "type": "string", + "title": "Target Type" + }, + "target_id": { + "type": "string", + "title": "Target Id" + }, + "actor": { + "type": "string", + "title": "Actor" + }, + "old_value": { + "anyOf": [ + { + "oneOf": [ + { + "$ref": "#/components/schemas/NodeAddedMutation" + }, + { + "$ref": "#/components/schemas/NodeRemovedMutation" + }, + { + "$ref": "#/components/schemas/NodeStatusChangedMutation" + }, + { + "$ref": "#/components/schemas/NodeFieldChangedMutation" + }, + { + "$ref": "#/components/schemas/EdgeAddedMutation" + }, + { + "$ref": "#/components/schemas/EdgeRemovedMutation" + }, + { + "$ref": "#/components/schemas/EdgeStatusChangedMutation" + }, + { + "$ref": "#/components/schemas/AnnotationSetMutation" + }, + { + "$ref": "#/components/schemas/AnnotationDeletedMutation" + } + ], + "discriminator": { + "propertyName": "mutation_type", + "mapping": { + "annotation.deleted": "#/components/schemas/AnnotationDeletedMutation", + "annotation.set": "#/components/schemas/AnnotationSetMutation", + "edge.added": "#/components/schemas/EdgeAddedMutation", + "edge.removed": "#/components/schemas/EdgeRemovedMutation", + "edge.status_changed": "#/components/schemas/EdgeStatusChangedMutation", + "node.added": "#/components/schemas/NodeAddedMutation", + "node.field_changed": "#/components/schemas/NodeFieldChangedMutation", + "node.removed": "#/components/schemas/NodeRemovedMutation", + "node.status_changed": "#/components/schemas/NodeStatusChangedMutation" + } + } + }, + { + "type": "null" + } + ], + "title": "Old Value" + }, + "new_value": { + "oneOf": [ + { + "$ref": "#/components/schemas/NodeAddedMutation" + }, + { + "$ref": "#/components/schemas/NodeRemovedMutation" + }, + { + "$ref": "#/components/schemas/NodeStatusChangedMutation" + }, + { + "$ref": "#/components/schemas/NodeFieldChangedMutation" + }, + { + "$ref": "#/components/schemas/EdgeAddedMutation" + }, + { + "$ref": "#/components/schemas/EdgeRemovedMutation" + }, + { + "$ref": "#/components/schemas/EdgeStatusChangedMutation" + }, + { + "$ref": "#/components/schemas/AnnotationSetMutation" + }, + { + "$ref": "#/components/schemas/AnnotationDeletedMutation" + } + ], + "title": "New Value", + "discriminator": { + "propertyName": "mutation_type", + "mapping": { + "annotation.deleted": "#/components/schemas/AnnotationDeletedMutation", + "annotation.set": "#/components/schemas/AnnotationSetMutation", + "edge.added": "#/components/schemas/EdgeAddedMutation", + "edge.removed": "#/components/schemas/EdgeRemovedMutation", + "edge.status_changed": "#/components/schemas/EdgeStatusChangedMutation", + "node.added": "#/components/schemas/NodeAddedMutation", + "node.field_changed": "#/components/schemas/NodeFieldChangedMutation", + "node.removed": "#/components/schemas/NodeRemovedMutation", + "node.status_changed": "#/components/schemas/NodeStatusChangedMutation" + } + } + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "created_at": { + "type": "string", + "title": "Created At" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "run_id", + "sequence", + "mutation_type", + "target_type", + "target_id", + "actor", + "old_value", + "new_value", + "reason", + "created_at" + ], + "title": "RunGraphMutationDto", + "description": "One entry in the append-only mutation log for a run.\n\nField names are snake_case to match the frontend GraphMutationDtoSchema.\nCamelModel is intentionally not used here \u2014 the frontend contract uses snake_case." + }, "RunResourceDto": { "properties": { "id": { @@ -1716,6 +2334,11 @@ "title": "Runningtasks", "default": 0 }, + "cancelledTasks": { + "type": "integer", + "title": "Cancelledtasks", + "default": 0 + }, "finalScore": { "anyOf": [ { @@ -2407,4 +3030,4 @@ } } } -} \ No newline at end of file +} diff --git a/ergon-dashboard/src/lib/runState.ts b/ergon-dashboard/src/lib/runState.ts index 8e6b269b..04c53a8f 100644 --- a/ergon-dashboard/src/lib/runState.ts +++ b/ergon-dashboard/src/lib/runState.ts @@ -151,6 +151,7 @@ export function deserializeRunState(input: unknown): WorkflowRunState { completedTasks: data.completedTasks, runningTasks: data.runningTasks, failedTasks: data.failedTasks, + cancelledTasks: data.cancelledTasks, finalScore: data.finalScore ?? null, error: data.error ?? null, edges: new Map(), diff --git a/ergon-dashboard/src/lib/state/store.ts b/ergon-dashboard/src/lib/state/store.ts index ac11fcd1..2ac40ad7 100644 --- a/ergon-dashboard/src/lib/state/store.ts +++ b/ergon-dashboard/src/lib/state/store.ts @@ -132,6 +132,7 @@ class DashboardStore { completedTasks: 0, runningTasks: 0, failedTasks: 0, + cancelledTasks: 0, finalScore: null, error: null, edges: new Map(), diff --git a/ergon-dashboard/src/lib/types.ts b/ergon-dashboard/src/lib/types.ts index 59507d92..8e7e9e02 100644 --- a/ergon-dashboard/src/lib/types.ts +++ b/ergon-dashboard/src/lib/types.ts @@ -340,6 +340,7 @@ export interface WorkflowRunState { completedTasks: number; runningTasks: number; failedTasks: number; + cancelledTasks: number; // Result finalScore: number | null; diff --git a/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json b/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json index 33275ab5..72ff1d0b 100644 --- a/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json +++ b/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json @@ -14,6 +14,7 @@ "completedTasks": 1, "runningTasks": 3, "failedTasks": 0, + "cancelledTasks": 0, "finalScore": null, "error": null, "tasks": { diff --git a/ergon-dashboard/tests/helpers/dashboardFixtures.ts b/ergon-dashboard/tests/helpers/dashboardFixtures.ts index 3173b575..beaabe38 100644 --- a/ergon-dashboard/tests/helpers/dashboardFixtures.ts +++ b/ergon-dashboard/tests/helpers/dashboardFixtures.ts @@ -284,6 +284,7 @@ function serializedRunState(): SerializedWorkflowRunState { completedTasks: 1, runningTasks: 1, failedTasks: 0, + cancelledTasks: 0, finalScore: null, error: null, }; diff --git a/ergon_core/ergon_core/core/api/startup_plugins.py b/ergon_core/ergon_core/core/api/startup_plugins.py index ba594f0a..c61c03fd 100644 --- a/ergon_core/ergon_core/core/api/startup_plugins.py +++ b/ergon_core/ergon_core/core/api/startup_plugins.py @@ -11,5 +11,5 @@ def run_startup_plugins(plugin_specs: tuple[str, ...]) -> None: f"Invalid ERGON_STARTUP_PLUGINS entry {spec!r}; expected 'module:function'" ) module = import_module(module_name) - plugin = getattr(module, attr_name) + plugin = getattr(module, attr_name) # slopcop: ignore[no-hasattr-getattr] plugin() diff --git a/ergon_core/ergon_core/core/providers/sandbox/event_sink.py b/ergon_core/ergon_core/core/providers/sandbox/event_sink.py index 2239e49d..296df796 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/event_sink.py +++ b/ergon_core/ergon_core/core/providers/sandbox/event_sink.py @@ -150,7 +150,10 @@ async def sandbox_created( timeout_minutes: int, template: str | None = None, ) -> None: - from ergon_core.core.persistence.telemetry.models import SandboxEvent + # reason: avoid import cycle with ergon_core.api package exports. + from ergon_core.core.persistence.telemetry.models import ( + SandboxEvent, + ) with get_session() as s: s.add( @@ -176,7 +179,10 @@ async def sandbox_command( exit_code: int | None = None, duration_ms: int | None = None, ) -> None: - from ergon_core.core.persistence.telemetry.models import SandboxCommandWalEntry + # reason: avoid import cycle with ergon_core.api package exports. + from ergon_core.core.persistence.telemetry.models import ( + SandboxCommandWalEntry, + ) with get_session() as s: s.add( @@ -202,7 +208,10 @@ async def sandbox_closed( ) -> None: if run_id is None: return - from ergon_core.core.persistence.telemetry.models import SandboxEvent + # reason: avoid import cycle with ergon_core.api package exports. + from ergon_core.core.persistence.telemetry.models import ( + SandboxEvent, + ) with get_session() as s: s.add( diff --git a/ergon_core/ergon_core/core/providers/sandbox/lifecycle.py b/ergon_core/ergon_core/core/providers/sandbox/lifecycle.py index ed13af23..33595810 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/lifecycle.py +++ b/ergon_core/ergon_core/core/providers/sandbox/lifecycle.py @@ -1,7 +1,5 @@ """Runtime-facing sandbox lifecycle helpers.""" -from __future__ import annotations - import logging from enum import StrEnum @@ -33,7 +31,10 @@ async def terminate_sandbox_by_id(sandbox_id: str | None) -> SandboxTerminationR ) try: - from ergon_core.core.providers.sandbox.manager import BaseSandboxManager + # reason: avoid import cycle between sandbox manager/event sink and telemetry models. + from ergon_core.core.providers.sandbox.manager import ( + BaseSandboxManager, + ) terminated = await BaseSandboxManager.terminate_by_sandbox_id(sandbox_id) except Exception: # slopcop: ignore[no-broad-except] diff --git a/ergon_core/ergon_core/test_support/sandbox/__init__.py b/ergon_core/ergon_core/test_support/sandbox/__init__.py index 0c21d80c..295929ef 100644 --- a/ergon_core/ergon_core/test_support/sandbox/__init__.py +++ b/ergon_core/ergon_core/test_support/sandbox/__init__.py @@ -5,9 +5,14 @@ __all__ = ["StubSandboxManager", "is_stub_sandbox_id"] -def __getattr__(name: str) -> object: +def __getattr__( + name: str, +) -> object: # slopcop: ignore[no-typing-any] -- module-level lazy export hook. if name == "StubSandboxManager": - from ergon_core.test_support.sandbox.stub_manager import StubSandboxManager + # reason: avoid importing manager/test doubles unless explicitly requested. + from ergon_core.test_support.sandbox.stub_manager import ( + StubSandboxManager, + ) return StubSandboxManager raise AttributeError(name) diff --git a/ergon_core/ergon_core/test_support/sandbox/sentinel.py b/ergon_core/ergon_core/test_support/sandbox/sentinel.py index 372ed856..1bc9abe8 100644 --- a/ergon_core/ergon_core/test_support/sandbox/sentinel.py +++ b/ergon_core/ergon_core/test_support/sandbox/sentinel.py @@ -3,5 +3,7 @@ STUB_SANDBOX_PREFIX = "stub-sandbox-" -def is_stub_sandbox_id(sandbox_id: object) -> bool: +def is_stub_sandbox_id( + sandbox_id: object, # slopcop: ignore[no-typing-any] -- sentinel check accepts arbitrary persisted JSON values. +) -> bool: return isinstance(sandbox_id, str) and sandbox_id.startswith(STUB_SANDBOX_PREFIX) diff --git a/ergon_core/ergon_core/test_support/sandbox/stub_manager.py b/ergon_core/ergon_core/test_support/sandbox/stub_manager.py index 2a04416b..1674ddb3 100644 --- a/ergon_core/ergon_core/test_support/sandbox/stub_manager.py +++ b/ergon_core/ergon_core/test_support/sandbox/stub_manager.py @@ -1,8 +1,7 @@ """Sandbox manager test double.""" -from __future__ import annotations - import logging +from typing import cast from uuid import UUID from ergon_core.core.providers.sandbox.manager import AsyncSandbox, BaseSandboxManager @@ -33,7 +32,7 @@ async def create( stub_id = f"{STUB_SANDBOX_PREFIX}{sandbox_key}" logger.info("Returning test stub sandbox id %s for task %s", stub_id, sandbox_key) self._ensure_registries(sandbox_key) - self._sandboxes[sandbox_key] = _StubSandbox(stub_id) # type: ignore[assignment] + self._sandboxes[sandbox_key] = cast("AsyncSandbox", _StubSandbox(stub_id)) self._run_ids[sandbox_key] = run_id self._display_task_ids[sandbox_key] = display_task_id or sandbox_key self._sandbox_manager_classes[sandbox_key] = type(self) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py index f6beaed2..ecdc78fe 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py @@ -143,7 +143,7 @@ async def execute( parent_node_id=context.node_id, ) if children and all(c.status in _CHILD_WAIT_TERMINAL_STATUSES for c in children): - self._last_child_statuses = {c.name: c.status for c in children} + self._last_child_statuses = {c.task_slug: c.status for c in children} break await asyncio.sleep(2) diff --git a/tests/unit/runtime/test_communication_service.py b/tests/unit/runtime/test_communication_service.py index abb294c3..f64c8115 100644 --- a/tests/unit/runtime/test_communication_service.py +++ b/tests/unit/runtime/test_communication_service.py @@ -33,7 +33,7 @@ async def test_save_message_persists_thread_summary_and_emits_it( ) -> None: emitted: list[tuple[object, object]] = [] - async def _record_thread_event(*, run_id, thread, message) -> None: # noqa: ANN001 + async def _record_thread_event(*, run_id: object, thread: object, message: object) -> None: emitted.append((thread, message)) monkeypatch.setattr(module, "get_session", session_factory) @@ -70,7 +70,7 @@ async def test_save_message_backfills_missing_summary_without_overwriting_existi monkeypatch: pytest.MonkeyPatch, session_factory, ) -> None: - async def _ignore_thread_event(*, run_id, thread, message) -> None: # noqa: ANN001 + async def _ignore_thread_event(*, run_id: object, thread: object, message: object) -> None: return None monkeypatch.setattr(module, "get_session", session_factory) From e9d92e5242fe4aa59b051c3b59017a26c049d63e Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:43:40 +0100 Subject: [PATCH 06/66] Fix CI build and migration head Keep generated REST contracts lint-clean, update the e2e workflow guard for parallel smoke jobs, and rebase the thread-summary migration onto the latest main migration head. Made-with: Cursor --- ergon-dashboard/scripts/generate-rest-contracts.mjs | 5 ++++- ergon-dashboard/src/generated/rest/contracts.ts | 1 + .../migrations/versions/0a1b2c3d4e5f_add_thread_summary.py | 2 +- tests/unit/smoke_base/test_e2e_workflow_limits.py | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ergon-dashboard/scripts/generate-rest-contracts.mjs b/ergon-dashboard/scripts/generate-rest-contracts.mjs index 00825f29..558239b9 100644 --- a/ergon-dashboard/scripts/generate-rest-contracts.mjs +++ b/ergon-dashboard/scripts/generate-rest-contracts.mjs @@ -19,4 +19,7 @@ if (markerIndex === -1) { const schemasOnlySource = source.slice(0, markerIndex).trimEnd(); -writeFileSync(contractsPath, `${schemasOnlySource}\n`); +writeFileSync( + contractsPath, + `/* eslint-disable @typescript-eslint/no-empty-object-type */\n${schemasOnlySource}\n`, +); diff --git a/ergon-dashboard/src/generated/rest/contracts.ts b/ergon-dashboard/src/generated/rest/contracts.ts index 615345e6..d013a213 100644 --- a/ergon-dashboard/src/generated/rest/contracts.ts +++ b/ergon-dashboard/src/generated/rest/contracts.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ import { z } from "zod"; type JsonValue = diff --git a/ergon_core/migrations/versions/0a1b2c3d4e5f_add_thread_summary.py b/ergon_core/migrations/versions/0a1b2c3d4e5f_add_thread_summary.py index a888473b..78f779cf 100644 --- a/ergon_core/migrations/versions/0a1b2c3d4e5f_add_thread_summary.py +++ b/ergon_core/migrations/versions/0a1b2c3d4e5f_add_thread_summary.py @@ -13,7 +13,7 @@ from alembic import op revision: str = "0a1b2c3d4e5f" -down_revision: Union[str, None] = "f6a7b8c9d0e1" +down_revision: Union[str, None] = "a2b3c4d5e6f7" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/tests/unit/smoke_base/test_e2e_workflow_limits.py b/tests/unit/smoke_base/test_e2e_workflow_limits.py index f869fb0c..5fc338ba 100644 --- a/tests/unit/smoke_base/test_e2e_workflow_limits.py +++ b/tests/unit/smoke_base/test_e2e_workflow_limits.py @@ -1,11 +1,11 @@ from pathlib import Path -def test_e2e_smoke_matrix_is_serialized_for_e2b_quota() -> None: +def test_e2e_smoke_matrix_runs_benchmarks_in_parallel() -> None: workflow = Path(".github/workflows/e2e-benchmarks.yml").read_text() strategy_start = workflow.index(" strategy:") runs_on_start = workflow.index(" runs-on:", strategy_start) strategy_block = workflow[strategy_start:runs_on_start] - assert " max-parallel: 1\n" in strategy_block + assert " max-parallel: 3\n" in strategy_block From 8e54706816c5bd8cc988b55b33417299a9723745 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:01:11 +0100 Subject: [PATCH 07/66] Add evaluation visibility and smoke coverage Made-with: Cursor --- docs/architecture/07_testing.md | 64 +- ...26-04-27-frontend-evaluation-visibility.md | 1390 +++++++++++++++++ .../components/cohorts/CohortDetailView.tsx | 48 +- .../src/components/dag/DAGCanvas.tsx | 55 + .../src/components/dag/TaskNode.tsx | 9 + .../src/components/panels/EvaluationPanel.tsx | 89 +- .../src/features/evaluation/contracts.ts | 19 + .../src/features/evaluation/selectors.test.ts | 150 ++ .../src/features/evaluation/selectors.ts | 84 + .../graph/components/ContainerNode.tsx | 13 + .../features/graph/components/LeafNode.tsx | 28 + .../graph/layout/hierarchicalLayout.ts | 25 +- ...oardTaskEvaluationUpdatedEvent.schema.json | 65 + .../src/generated/rest/contracts.ts | 30 + .../src/generated/rest/openapi.json | 132 +- .../tests/contracts/contracts.test.ts | 2 + ergon-dashboard/tests/e2e/_shared/expected.ts | 2 + ergon-dashboard/tests/e2e/_shared/smoke.ts | 22 +- .../fixtures/mas-runs/concurrent-mas-run.json | 9 + .../tests/helpers/dashboardFixtures.ts | 44 + ergon_cli/ergon_cli/composition/__init__.py | 15 +- ergon_core/ergon_core/core/api/runs.py | 9 + ergon_core/ergon_core/core/api/schemas.py | 13 +- .../telemetry/evaluation_summary.py | 8 + .../core/runtime/services/cohort_schemas.py | 17 + .../core/runtime/services/cohort_service.py | 61 +- .../evaluation_persistence_service.py | 54 +- .../test_support/sandbox/__init__.py | 19 +- .../test_support/smoke_fixtures/__init__.py | 10 +- .../test_support/smoke_fixtures/benchmarks.py | 4 +- .../smoke_fixtures/criteria/timing.py | 45 + .../smoke_base/criterion_base.py | 29 +- .../smoke_fixtures/smoke_base/recursive.py | 184 +++ .../smoke_fixtures/workers/minif2f_smoke.py | 15 +- .../workers/researchrubrics_smoke.py | 15 +- .../smoke_fixtures/workers/swebench_smoke.py | 15 +- ...c9d0_normalize_evaluation_summary_nulls.py | 36 +- tests/e2e/_asserts.py | 116 +- tests/e2e/test_minif2f_smoke.py | 76 +- tests/e2e/test_researchrubrics_smoke.py | 61 +- tests/e2e/test_swebench_smoke.py | 76 +- .../test_cohort_rubric_status_summary.py | 93 ++ .../test_dynamic_task_evaluation_mapping.py | 3 + .../test_evaluation_summary_contracts.py | 78 +- .../unit/runtime/test_smoke_topology_drift.py | 15 + .../smoke_base/test_e2e_smoke_driver_pairs.py | 44 + .../test_recursive_smoke_worker_routing.py | 46 + .../smoke_base/test_registry_smoke_entries.py | 4 + .../test_smoke_composition_bindings.py | 40 + 49 files changed, 3332 insertions(+), 149 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-27-frontend-evaluation-visibility.md create mode 100644 ergon-dashboard/src/features/evaluation/contracts.ts create mode 100644 ergon-dashboard/src/features/evaluation/selectors.test.ts create mode 100644 ergon-dashboard/src/features/evaluation/selectors.ts create mode 100644 ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py create mode 100644 ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py create mode 100644 tests/unit/runtime/test_cohort_rubric_status_summary.py create mode 100644 tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py create mode 100644 tests/unit/smoke_base/test_recursive_smoke_worker_routing.py create mode 100644 tests/unit/smoke_base/test_smoke_composition_bindings.py diff --git a/docs/architecture/07_testing.md b/docs/architecture/07_testing.md index 292d5212..f383dd65 100644 --- a/docs/architecture/07_testing.md +++ b/docs/architecture/07_testing.md @@ -25,17 +25,17 @@ Path-based, not marker-based. The local gate and the CI workflow both dispatch b Every PR runs three benchmark legs in parallel via `.github/workflows/e2e-benchmarks.yml`: -| Leg | Slot 1 | Slot 2 | Slot 3 | -|---|---|---|---| -| `researchrubrics` | happy | happy | **sad** — `l_2` forced FAIL | -| `minif2f` | happy | happy | happy | -| `swebench-verified` | happy | happy | happy | +| Leg | Slot 1 | Slot 2 | +|---|---|---| +| `researchrubrics` | happy | **sad** — `l_2` forced FAIL | +| `minif2f` | happy | **sad** — `l_2` forced FAIL | +| `swebench-verified` | happy | **sad** — `l_2` forced FAIL | -**9 top-level runs per PR; 80 leaf sandbox acquisitions** (8 happy × 9 leaves + 1 sad × 8 leaves — `l_3` never provisioned because its dependency failed). +**6 top-level runs per PR; 57 dynamic child sandbox acquisitions** (3 happy × 11 child tasks + 3 sad × 8 child tasks — `l_3` never provisions on sad runs because its dependency failed). -### 3.1 Immutable 9-leaf DAG +### 3.1 Smoke DAG -Every smoke run — happy or sad — spawns exactly this graph: +Every smoke run starts with the same 9 direct children: ``` Diamond (4): Line (3): Singletons (2): @@ -46,9 +46,18 @@ d_left d_right d_join ``` -Topology is enforced by `tests/e2e/_fixtures/smoke_base/worker_base.py::SmokeWorkerBase.execute` being decorated `@typing.final`. Subclasses supply the leaf slug via `leaf_slug` and (optionally) override `_spec_for(slug, deps, desc)` to route specific slugs elsewhere — the sad-path subclass uses this to route `l_2` to a failing leaf. They cannot change the DAG itself. +Happy-path runs route top-level `l_2` to `{env}-smoke-recursive-worker`, which plans a nested two-node line under `l_2`: -The single source of truth for topology is [`tests/e2e/_fixtures/smoke_base/constants.py`](../../tests/e2e/_fixtures/smoke_base/constants.py): +```text +l_2 +└─ l_2_a → l_2_b +``` + +Top-level `l_3` depends on `l_2`, so the smoke proves dependency propagation waits for a non-leaf dynamic task before releasing downstream work. Sad-path runs route `l_2` to the failing leaf instead, so `l_3` remains blocked. + +Topology is enforced by `ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py::SmokeWorkerBase.execute` being decorated `@typing.final`. Subclasses supply the leaf slug via `leaf_slug` and override `_spec_for(slug, deps, desc)` only to route specific slugs elsewhere. They cannot change the direct-child DAG itself. + +The single source of truth for the direct-child topology is [`ergon_core/test_support/smoke_fixtures/smoke_base/constants.py`](../../ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/constants.py): ```python EXPECTED_SUBTASK_SLUGS = ( @@ -60,29 +69,32 @@ EXPECTED_SUBTASK_SLUGS = ( ### 3.2 Fixture residency — test-only, out of `ergon_builtins` -`ergon_builtins/` contains only production baselines (ReActWorker, TrainingStubWorker). All smoke workers, leaves, and criteria live under [`tests/e2e/_fixtures/`](../../tests/e2e/_fixtures/) and register into the process-level `WORKERS` / `EVALUATORS` dicts via an import side-effect in `tests/e2e/_fixtures/__init__.py`, which `tests/e2e/conftest.py` imports at session start. +`ergon_builtins/` contains only production baselines (ReActWorker, TrainingStubWorker). All smoke workers, leaves, and criteria live under [`ergon_core/test_support/smoke_fixtures/`](../../ergon_core/ergon_core/test_support/smoke_fixtures/) and register into the process-level `WORKERS` / `EVALUATORS` dicts through `register_smoke_fixtures()`. -11 registry rows total — none production: +19 registry rows total — none production: | Slug | Kind | |---|---| | `{env}-smoke-worker` × 3 | Worker (parent) — inherits `SmokeWorkerBase` | | `{env}-smoke-leaf` × 3 | Worker (leaf) — inherits `BaseSmokeLeafWorker` | -| `researchrubrics-sadpath-smoke-worker` | Worker (sad-path parent) | -| `researchrubrics-smoke-leaf-failing` | Worker (sad-path failing leaf) | +| `{env}-smoke-recursive-worker` × 3 | Worker (nested `l_2` parent) — inherits `RecursiveSmokeWorkerBase` | +| `{env}-sadpath-smoke-worker` × 3 | Worker (sad-path parent) | +| `{env}-smoke-leaf-failing` × 3 | Worker (sad-path failing leaf) | | `{env}-smoke-criterion` × 3 | Criterion — inherits `SmokeCriterionBase` | +| `smoke-post-root-timing-criterion` | Criterion — second root evaluator used for timing assertions | where `{env} ∈ {researchrubrics, minif2f, swebench}`. ### 3.3 Turn persistence - Parent `SmokeWorkerBase.execute` yields **3** `GenerationTurn`s (planning → planned → awaiting) so incremental turn persistence is exercised on every run. +- Happy-path recursive `l_2` yields **3** `GenerationTurn`s. - Each leaf `BaseSmokeLeafWorker.execute` yields **2** turns (attaching → done). -- Total per happy run: **1 × 3 + 9 × 2 = 21** `GenerationTurn` rows; driver asserts on this. +- Total per happy run: **3 + 3 + 10 × 2 = 26** `GenerationTurn` rows; driver asserts on this. ### 3.4 Inter-agent messaging -Each happy-path leaf calls `CommunicationService.save_message` once on the `smoke-completion` thread (first production caller of that service). 9 `ThreadMessage` rows per happy run, sequence_num 1..9 per thread. Sad-path `l_2` raises before reaching this call — 8 messages on a sad run, with `l_2` missing. +Each happy-path leaf calls `CommunicationService.save_message` once on the `smoke-completion` thread (first production caller of that service). The recursive `l_2` worker also sends one completion message after nested children finish. Happy runs emit 11 `ThreadMessage` rows (`9` direct slugs + `l_2_a`, `l_2_b`), sequence_num 1..11 per thread. Sad-path `l_2` raises before reaching this call and `l_3` blocks — 7 messages on a sad run, with `l_2` and `l_3` missing. ### 3.5 Sandbox-side checks @@ -98,14 +110,14 @@ For each run in a cohort, the pytest driver asserts: | Channel | What it checks | |---|---| -| `RunGraphNode` | 10 nodes (1 root + 9 leaves); all COMPLETED (happy) or cascade pattern (sad); `sorted(slugs) == EXPECTED_SUBTASK_SLUGS` | -| `RunGraphEdge` | 6 expected dependency edges (diamond + line) | -| `RunResource` | ≥ 18 rows (9 outputs + 9 probes); all with non-empty `content_hash` | -| `GenerationTurn` | Exactly 21 rows per happy run (derived from `PARENT_TURN_COUNT + 9 × LEAF_TURN_COUNT`) | -| `ThreadMessage` (topic `smoke-completion`) | 9 messages per happy run / 8 per sad; `sequence_num` strictly 1..N | +| `RunGraphNode` | Happy: 12 nodes (1 root + 9 direct children + 2 nested children), all COMPLETED; sad: cascade pattern with `l_2` FAILED and `l_3` BLOCKED | +| `RunGraphEdge` | Expected dependency edges (diamond, top-level line, nested `l_2_a → l_2_b`) | +| `RunResource` | Happy: 20 rows (10 outputs + 10 probes); all with non-empty `content_hash` | +| `GenerationTurn` | Exactly 26 rows per happy run | +| `ThreadMessage` (topic `smoke-completion`) | 11 messages per happy run / 7 per sad; `sequence_num` strictly 1..N | | Blob store round-trip | Re-read of one probe JSON is byte-stable + parses | | Temporal ordering | `RunTaskExecution.started_at` of children ≥ `completed_at` of parents | -| `RunTaskEvaluation` | Exactly 1 row; score 1.0 (happy) / 0.0 (sad); failed slug named in sad feedback | +| `RunTaskEvaluation` | Happy: 2 root rows, both score 1.0 and created after root execution completion; sad: no successful final score | Sad-path adds: partial artifact persisted (partial_*.md exists as RunResource), pre-failure WAL entry present, `l_3` status BLOCKED/CANCELLED per RFC `static-sibling-failure-semantics`. @@ -153,7 +165,7 @@ Required `data-testid` attributes: `run-status`, `task-node-{slug}` (one per `EX 3. **Test stubs live in `tests/e2e/_fixtures/`, not `ergon_builtins/`.** Production registry (`ergon_builtins/registry_core.py`) contains only production baselines. Exception: `training_stub_worker.py` — it's a real RL-trajectory baseline, not test scaffolding; operators invoke it via CLI. 4. **Criteria reconnect via the CriterionRuntime DI container, never via `AsyncSandbox.connect` directly.** Enforced by code inspection; the anti-pattern previously fixed by `bugs/fixed/2026-04-18-swebench-criterion-spawns-sandbox.md`. 5. **Sandbox outlives the task until all criteria finish.** RFC `sandbox-lifetime-covers-criteria`. Smoke is the living regression test for this. -6. **Cohort parallelism exercised on every PR.** 3-run cohorts prove concurrent workflow submission and cohort aggregation at the scale smoke uses. +6. **Cohort parallelism exercised on every PR.** 2-run happy/sad cohorts prove concurrent workflow submission and cohort aggregation at the scale smoke uses. 7. **Partial work persists on FAILED leaves.** Sad-path `AlwaysFailSubworker` writes a file + runs a probe command, then raises. Driver asserts the partial artifact and pre-failure WAL entry survive. ## 9. Budget @@ -161,10 +173,10 @@ Required `data-testid` attributes: `run-status`, `task-node-{slug}` (one per `EX | Measure | Value | |---|---| | Per matrix leg | 10-min job timeout; 5-min pytest timeout | -| Leaf-subtask sandbox acquisitions per leg | 26 or 27 (researchrubrics has 26 because the sad slot skips `l_3`) | -| Leaf-subtask sandbox acquisitions per PR | 80 across 3 sandbox images | +| Dynamic child sandbox acquisitions per leg | 19 (1 happy × 11 child tasks + 1 sad × 8 child tasks) | +| Dynamic child sandbox acquisitions per PR | 57 across 3 sandbox images | | Parent-task sandbox per run | 1 (used by parent worker + attached to by the criterion). Not additional at evaluation time. | -| Parallel workflow runs per PR | 9 (3 legs × 3-run cohort) | +| Parallel workflow runs per PR | 6 (3 legs × 2-run cohort) | | Warm wall-clock per leg | 1–3 min (post-Docker cache) | | Cold wall-clock per leg | up to 5 min | diff --git a/docs/superpowers/plans/2026-04-27-frontend-evaluation-visibility.md b/docs/superpowers/plans/2026-04-27-frontend-evaluation-visibility.md new file mode 100644 index 00000000..76e79b86 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-frontend-evaluation-visibility.md @@ -0,0 +1,1390 @@ +# Frontend Evaluation Visibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the evaluation feature set from the design brief to the dashboard: cohort rubric status pips, graph node rubric cues, skipped/error states, rubric metadata, richer evaluation drawer details, container roll-ups, and an evaluation lens. + +**Implementation note:** The first implementation keeps the original API strategy: additive backend fields, frontend-derived run/container roll-ups, backend-owned cohort summaries, and stable `data-testid` coverage for cohort pips, graph rubric glyphs, the evaluation lens toggle, and criterion status details. + +**Architecture:** Keep the backend read model additive and make the frontend own presentation-specific selectors in a new `features/evaluations` domain. Enrich existing `GET /runs/{run_id}` and `GET /cohorts/{cohort_id}` payloads rather than introducing a new fetch path for the first implementation. Keep E2E assertions anchored to stable `data-testid` attributes and the backend harness DTO. + +**Tech Stack:** FastAPI, Pydantic DTOs, SQLModel persistence, Next.js App Router, React, TypeScript, Zod, React Flow, Playwright, pytest. + +--- + +## RFC + +### Problem + +The backend now produces enough evaluation data to validate task-level correctness, but the dashboard still treats evaluation as a narrow workspace tab. The design brief expects evaluation to be visible across the debugging loop: + +- Cohort rows show per-run rubric status pips and failure/skipped state at a glance. +- Graph nodes show which tasks have attached rubrics without requiring a click. +- Container nodes summarize evaluation status for their descendant tasks. +- The evaluation tab explains score composition, weights, skipped criteria, evaluator errors, input, feedback, and timing. +- Operators can switch the DAG into an evaluation lens that highlights evaluation-bearing tasks and dims unrelated work. + +### Non-Goals + +- Do not change evaluation execution semantics. +- Do not add interactive re-evaluation controls. +- Do not introduce a new standalone evaluation API service. +- Do not persist new relational tables unless the additive summary JSON fields prove insufficient. + +### Source Of Truth + +Use persisted `RunTaskEvaluation` rows and their typed `summary_json` as the source of truth. The frontend should not infer evaluation status from task status alone. It may derive roll-ups from evaluation rows and task parent/child relationships. + +### Nullability And Defaults Policy + +Avoid silent defaults at contract boundaries. If a field is owned by the backend and is required for rendering, make it required in the DTO and populate it explicitly in the builder. Use `None`/`null` only for genuinely absent data such as optional model reasoning, optional feedback, optional evaluation input, or optional error detail. In frontend derived state, represent "there is no evaluation evidence" as `null`, not as an all-zero roll-up object with a `"none"` sentinel. + +### API Strategy + +Use existing endpoints with additive fields: + +- `GET /runs/{run_id}` returns the enriched `RunSnapshotDto`. +- `GET /cohorts/{cohort_id}` returns enriched `CohortRunRowDto` rows with lightweight rubric status summaries. +- `GET /api/test/read/run/{run_id}/state` returns the expanded smoke harness fields used by Playwright. + +No existing response field should be removed or renamed. + +### Evaluation Status Semantics + +Use one canonical status vocabulary everywhere: + +```python +EvalCriterionStatus = Literal["passed", "failed", "errored", "skipped"] +RubricStatusSummaryStatus = Literal["passing", "failing", "errored", "skipped", "mixed", "none"] +``` + +Criterion status rules: + +- `errored`: `error` is non-null. +- `skipped`: criterion was part of the evaluator spec but did not execute because a prior gate failed or the attached task never reached the required lifecycle point. +- `passed`: criterion executed and `passed` is true. +- `failed`: criterion executed and `passed` is false. + +Roll-up status rules: + +- `none`: no evaluation rows or criteria. +- `errored`: at least one errored criterion. +- `failing`: at least one failed criterion and no errors. +- `mixed`: passed plus skipped criteria with no failed or errored criteria. +- `skipped`: all known criteria skipped. +- `passing`: all known criteria passed. + +### Backend Contract Additions + +Do not add parallel DTOs for data the run snapshot already exposes. The codebase already has: + +- `RunEvaluationCriterionDto` +- `RunTaskEvaluationDto` +- `RunSnapshotDto.evaluations_by_task` +- `CohortRunRowDto` + +The implementation should extend those existing DTOs in place. Graph glyphs, task roll-ups, container roll-ups, and run-level detail roll-ups should be derived in frontend selectors from `RunSnapshotDto.evaluations_by_task`. + +The only new backend DTO shape needed for the first implementation is a lightweight cohort-row rubric status summary, because the cohort page should show pips without fetching every run snapshot. The backend should own this summary, including counts and aggregate status. Keep the implementation direct: one compact builder over persisted `EvaluationSummary` rows, not a chain of helper functions or a second generic roll-up subsystem. + +Extend `ergon_core/ergon_core/core/api/schemas.py`: + +```python +from typing import Literal + +EvalCriterionStatus = Literal["passed", "failed", "errored", "skipped"] +``` + +Add fields to the existing `RunEvaluationCriterionDto` class: + +```python +class RunEvaluationCriterionDto(CamelModel): + # existing fields stay unchanged + criterion_name: str + status: EvalCriterionStatus + passed: bool + weight: float + contribution: float + model_reasoning: str | None = None + skipped_reason: str | None = None +``` + +Add fields to the existing `RunTaskEvaluationDto` class: + +```python +class RunTaskEvaluationDto(CamelModel): + # existing fields stay unchanged + evaluator_name: str + aggregation_rule: str +``` + +Add one lightweight DTO in `ergon_core/ergon_core/core/runtime/services/cohort_schemas.py`: + +```python +class CohortRubricStatusSummaryDto(BaseModel): + status: RubricStatusSummaryStatus + total_criteria: int + passed: int + failed: int + errored: int + skipped: int + criterion_statuses: list[str] + evaluator_names: list[str] + + +class CohortRunRowDto(BaseModel): + # existing fields stay unchanged + rubric_status_summary: CohortRubricStatusSummaryDto +``` + +### Frontend Contract Additions + +The generated REST contracts feed `ergon-dashboard/src/lib/contracts/rest.ts`. After regenerating contracts, normalize only fields that are genuinely optional on the backend contract. Do not use frontend defaults to hide missing required fields such as criterion `status`, criterion `weight`, evaluator name, aggregation rule, or cohort `rubric_status_summary`. + +Add frontend-only derived roll-up types in `ergon-dashboard/src/features/evaluations/contracts.ts`; do not mirror them as run-snapshot backend DTOs: + +```ts +export type EvalCriterionStatus = "passed" | "failed" | "errored" | "skipped"; +export type EvalRollupStatus = "passing" | "failing" | "errored" | "skipped" | "mixed"; +export type RubricStatusSummaryStatus = EvalRollupStatus | "none"; + +export interface EvaluationRollup { + status: EvalRollupStatus; + totalCriteria: number; + passed: number; + failed: number; + errored: number; + skipped: number; + normalizedScore: number; + maxScore: number; + evaluatorNames: string[]; + attachedTaskIds: string[]; + criterionStatuses: EvalCriterionStatus[]; +} +``` + +Extend existing normalized REST types in `ergon-dashboard/src/lib/contracts/rest.ts`: + +```ts +export interface RunEvaluationCriterion { + id: string; + stageNum: number; + stageName: string; + criterionNum: number; + criterionType: string; + criterionDescription: string; + criterionName: string; + status: EvalCriterionStatus; + passed: boolean; + weight: number; + contribution: number; + evaluationInput: string | null; + score: number; + maxScore: number; + feedback: string | null; + modelReasoning: string | null; + skippedReason: string | null; + evaluatedActionIds: string[]; + evaluatedResourceIds: string[]; + error: Record | null; +} +``` + +### Frontend Domain Boundary + +Create a focused evaluation domain: + +```text +ergon-dashboard/src/features/evaluations/ + contracts.ts + status.ts + selectors.ts + selectors.test.ts + components/ + CriterionStatusPip.tsx + RubricStatusStrip.tsx + EvaluationNodeGlyph.tsx + EvaluationRollupBadge.tsx + EvaluationLensToggle.tsx + EvaluationCriterionCard.tsx + EvaluationMetadataSummary.tsx +``` + +Responsibilities: + +- `contracts.ts`: frontend-only types if the generated REST types are too broad for component props. +- `status.ts`: colors, labels, icons, and ordering for evaluation statuses. +- `selectors.ts`: pure roll-up helpers for run, task, container descendants, and cohort rows. +- `components/*`: small visual components with stable `data-testid` attributes. + +### UX Contract + +Use these stable test IDs: + +- `cohort-eval-strip-{run_id}` +- `cohort-eval-pip-{run_id}-{index}` +- `graph-eval-glyph-{task_id}` +- `graph-eval-rollup-{task_id}` +- `graph-eval-lens-toggle` +- `workspace-evaluation-metadata` +- `workspace-evaluation-criterion-{criterion_id}` +- `workspace-evaluation-criterion-status-{criterion_id}` +- `workspace-evaluation-input-{criterion_id}` +- `workspace-evaluation-reasoning-{criterion_id}` + +### Acceptance Criteria + +- Cohort run rows render a rubric status strip for runs with evaluations and an empty state for runs without evaluations. +- Graph task nodes with attached evaluations render a subtle diamond glyph using text or CSS, with an accessible label. +- Expanded graph containers render a roll-up badge computed from descendant task evaluations. +- Evaluation lens dims non-evaluated tasks and highlights tasks with direct or descendant evaluation evidence. +- Evaluation panel shows aggregation rule, weights, score contribution, status, input, feedback, model reasoning, skipped reasons, and error details. +- Existing smoke specs assert happy-path passing pips, sad-path failed/skipped/errored visibility, graph glyphs, and the evaluation drawer. + +--- + +## File Structure + +### Backend Files + +- Modify `ergon_core/ergon_core/core/api/schemas.py`: extend existing evaluation DTO fields only. +- Modify `ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py`: persist criterion `status`, optional `model_reasoning`, and optional `skipped_reason` in `summary_json`. +- Modify `ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py`: build criterion status, contribution, and model reasoning from `CriterionResult.metadata`. +- Modify `ergon_core/ergon_core/core/api/runs.py`: pass enriched criterion fields through existing `evaluations_by_task`. +- Modify `ergon_core/ergon_core/core/runtime/services/run_read_service.py`: keep using existing `evaluations_by_task`; no new run-snapshot roll-up fields. +- Modify `ergon_core/ergon_core/core/runtime/services/cohort_schemas.py`: add `rubric_status_summary` to cohort run rows. +- Modify `ergon_core/ergon_core/core/runtime/services/cohort_service.py`: query run evaluations and attach a backend-owned rubric status summary. +- Modify `ergon_core/ergon_core/core/api/test_harness.py`: expose criterion statuses and a lightweight run rubric status summary to Playwright smoke tests. +- Test `tests/unit/runtime/test_evaluation_summary_contracts.py`: assert enriched summary fields. +- Test `tests/unit/runtime/test_cohort_rubric_status_summary.py`: assert cohort row rubric status summary. + +### Frontend Files + +- Regenerate `ergon-dashboard/src/generated/rest/contracts.ts` after backend schema updates. +- Modify `ergon-dashboard/src/lib/contracts/rest.ts`: normalize additive evaluation fields. +- Modify `ergon-dashboard/src/lib/types.ts`: export enriched evaluation aliases only. +- Modify `ergon-dashboard/src/lib/runState.ts`: deserialize enriched existing evaluations only. +- Create `ergon-dashboard/src/features/evaluations/status.ts`: central status display mapping. +- Create `ergon-dashboard/src/features/evaluations/selectors.ts`: pure derived state helpers. +- Test `ergon-dashboard/src/features/evaluations/selectors.test.ts`: assert direct and container roll-ups. +- Create `ergon-dashboard/src/features/evaluations/components/CriterionStatusPip.tsx`. +- Create `ergon-dashboard/src/features/evaluations/components/RubricStatusStrip.tsx`. +- Create `ergon-dashboard/src/features/evaluations/components/EvaluationNodeGlyph.tsx`. +- Create `ergon-dashboard/src/features/evaluations/components/EvaluationRollupBadge.tsx`. +- Create `ergon-dashboard/src/features/evaluations/components/EvaluationLensToggle.tsx`. +- Create `ergon-dashboard/src/features/evaluations/components/EvaluationCriterionCard.tsx`. +- Create `ergon-dashboard/src/features/evaluations/components/EvaluationMetadataSummary.tsx`. +- Modify `ergon-dashboard/src/components/cohorts/CohortDetailView.tsx`: render cohort run rubric status strips. +- Modify `ergon-dashboard/src/components/dag/TaskNode.tsx`: pass evaluation roll-up props. +- Modify `ergon-dashboard/src/features/graph/components/LeafNode.tsx`: render glyph and roll-up badge. +- Modify `ergon-dashboard/src/features/graph/components/ContainerNode.tsx`: render container roll-up badge. +- Modify `ergon-dashboard/src/components/dag/DAGCanvas.tsx`: add evaluation lens toggle and graph dimming behavior. +- Modify `ergon-dashboard/src/components/panels/EvaluationPanel.tsx`: render richer metadata and criterion cards. +- Modify `ergon-dashboard/tests/helpers/backendHarnessClient.ts`: expand backend harness DTO. +- Modify `ergon-dashboard/tests/e2e/_shared/smoke.ts`: assert the visible evaluation features. + +--- + +## Implementation Tasks + +### Task 1: Backend Evaluation Read Contract + +**Files:** +- Modify: `ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py` +- Modify: `ergon_core/ergon_core/core/api/schemas.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py` +- Test: `tests/unit/runtime/test_evaluation_summary_contracts.py` + +- [ ] **Step 1: Write failing summary contract tests** + +Add tests that prove the persistence DTO carries status, weights, contribution, and optional reasoning: + +```python +def test_build_evaluation_summary_includes_status_weight_and_contribution() -> None: + result = _service_result( + criterion_score=0.5, + criterion_weight=2.0, + passed=False, + metadata={"model_reasoning": "missing supporting artifact"}, + ) + + summary = build_evaluation_summary(result, evaluation_input="task evidence") + + entry = summary.criterion_results[0] + assert entry.status == "failed" + assert entry.weight == 2.0 + assert entry.contribution == 0.5 + assert entry.model_reasoning == "missing supporting artifact" + assert entry.skipped_reason is None + + +def test_dashboard_evaluation_dto_includes_criterion_status_fields() -> None: + summary = EvaluationSummary( + evaluator_name="post-root", + max_score=1.0, + normalized_score=1.0, + stages_evaluated=1, + stages_passed=1, + criterion_results=[ + CriterionResultEntry( + criterion_name="timing", + criterion_type="smoke-post-root-timing-criterion", + criterion_description="post root timing", + status="passed", + score=1.0, + max_score=1.0, + passed=True, + weight=1.0, + contribution=1.0, + ) + ], + ) + + dto = build_dashboard_evaluation_dto( + evaluation_id=UUID("00000000-0000-0000-0000-000000000001"), + run_id=UUID("00000000-0000-0000-0000-000000000002"), + task_id=UUID("00000000-0000-0000-0000-000000000003"), + total_score=1.0, + created_at=datetime(2026, 4, 27, tzinfo=UTC), + summary=summary, + ) + + criterion = dto.criterion_results[0] + assert criterion.status == "passed" + assert criterion.passed is True + assert criterion.weight == 1.0 + assert criterion.contribution == 1.0 + assert dto.evaluator_name == "post-root" + assert dto.aggregation_rule == "weighted_sum" +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest tests/unit/runtime/test_evaluation_summary_contracts.py -q` + +Expected: failure mentioning missing fields such as `status`, `contribution`, or `evaluator_name`. + +- [ ] **Step 3: Add typed persistence fields** + +In `evaluation_summary.py`, extend `CriterionResultEntry`: + +```python +class CriterionResultEntry(BaseModel): + """One criterion result as stored in the evaluation summary.""" + + criterion_name: str + criterion_type: str + stage_num: int + stage_name: str + criterion_num: int + status: Literal["passed", "failed", "errored", "skipped"] + score: float + max_score: float + passed: bool + weight: float + contribution: float + criterion_description: str + feedback: str | None = None + model_reasoning: str | None = None + skipped_reason: str | None = None + evaluation_input: str | None = None + evaluated_action_ids: list[str] = Field(default_factory=list) + evaluated_resource_ids: list[str] = Field(default_factory=list) + error: dict | None = None +``` + +- [ ] **Step 4: Add DTO fields** + +In `schemas.py`, update `RunEvaluationCriterionDto` and `RunTaskEvaluationDto` with the RFC contract fields. + +- [ ] **Step 5: Build status and metadata in persistence** + +In `evaluation_persistence_service.py`, add a helper: + +```python +def _criterion_status(*, passed: bool, error: dict | None) -> str: + if error is not None: + return "errored" + return "passed" if passed else "failed" +``` + +Then populate the entry: + +```python +metadata = cr.metadata +model_reasoning = metadata.get("model_reasoning") +entries.append( + CriterionResultEntry( + criterion_name=cr.name, + criterion_type=spec.criterion.type_slug, + criterion_description=spec.criterion.name, + stage_num=spec.stage_idx, + stage_name=spec.stage_name, + criterion_num=spec.criterion_idx, + status=_criterion_status(passed=cr.passed, error=None), + score=cr.score, + max_score=spec.max_score, + passed=cr.passed, + weight=cr.weight, + contribution=cr.score, + feedback=cr.feedback, + model_reasoning=model_reasoning if isinstance(model_reasoning, str) else None, + evaluation_input=evaluation_input, + ) +) +``` + +- [ ] **Step 6: Run tests and verify pass** + +Run: `pytest tests/unit/runtime/test_evaluation_summary_contracts.py -q` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py ergon_core/ergon_core/core/api/schemas.py ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py tests/unit/runtime/test_evaluation_summary_contracts.py +git commit -m "feat: enrich evaluation read contract" +``` + +### Task 2: Backend Cohort Rubric Status Summary + +**Files:** +- Modify: `ergon_core/ergon_core/core/api/runs.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/run_read_service.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/cohort_schemas.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/cohort_service.py` +- Modify: `ergon_core/ergon_core/core/api/test_harness.py` +- Test: `tests/unit/runtime/test_cohort_rubric_status_summary.py` + +- [ ] **Step 1: Write failing cohort rubric summary tests** + +Create `tests/unit/runtime/test_cohort_rubric_status_summary.py`: + +```python +def test_cohort_run_row_includes_rubric_status_summary(session: Session) -> None: + cohort, run, node = _persist_run_with_one_failed_evaluation(session) + + detail = experiment_cohort_service.get_detail(cohort.id) + + assert detail is not None + row = detail.runs[0] + assert row.rubric_status_summary.status == "failing" + assert row.rubric_status_summary.total_criteria == 1 + assert row.rubric_status_summary.failed == 1 + assert row.rubric_status_summary.criterion_statuses == ["failed"] +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: + +```bash +pytest tests/unit/runtime/test_cohort_rubric_status_summary.py -q +``` + +Expected: missing `rubric_status_summary` field or summary builder. + +- [ ] **Step 3: Implement one compact rubric summary builder** + +Add one private helper in `cohort_service.py`. Use `Counter` so the code says what it is doing without a separate status helper: + +```python +from collections import Counter + + +def _rubric_status_summary( + summaries: list[EvaluationSummary], +) -> CohortRubricStatusSummaryDto: + statuses = [ + criterion.status + for summary in summaries + for criterion in summary.criterion_results + ] + counts = Counter(statuses) + + if not statuses: + status = "none" + elif counts["errored"]: + status = "errored" + elif counts["failed"]: + status = "failing" + elif counts["passed"] and counts["skipped"]: + status = "mixed" + elif counts["skipped"] == len(statuses): + status = "skipped" + else: + status = "passing" + + return CohortRubricStatusSummaryDto( + status=status, + total_criteria=len(statuses), + passed=counts["passed"], + failed=counts["failed"], + errored=counts["errored"], + skipped=counts["skipped"], + criterion_statuses=statuses, + evaluator_names=sorted({summary.evaluator_name for summary in summaries}), + ) +``` + +- [ ] **Step 4: Attach cohort row rubric summary** + +In `cohort_service.py`, query `RunTaskEvaluation` for cohort runs, group by `run_id`, convert `summary_json` to `EvaluationSummary`, and pass `rubric_status_summary` into `_build_run_row`. + +- [ ] **Step 5: Expand test harness state** + +In `test_harness.py`, add these fields to the run state JSON: + +```json +{ + "rubric_status_summary": { + "status": "passing", + "total_criteria": 2, + "passed": 2, + "failed": 0, + "errored": 0, + "skipped": 0 + }, + "evaluations": [ + { + "task_id": "node-uuid", + "task_slug": "d_root", + "score": 1.0, + "reason": "root timing marker criterion ran", + "criterion_statuses": ["passed"], + "evaluator_name": "post-root" + } + ] +} +``` + +- [ ] **Step 6: Run backend tests** + +Run: + +```bash +pytest tests/unit/runtime/test_evaluation_summary_contracts.py tests/unit/runtime/test_cohort_rubric_status_summary.py -q +``` + +Expected: all selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add ergon_core/ergon_core/core/api/runs.py ergon_core/ergon_core/core/runtime/services/run_read_service.py ergon_core/ergon_core/core/runtime/services/cohort_schemas.py ergon_core/ergon_core/core/runtime/services/cohort_service.py ergon_core/ergon_core/core/api/test_harness.py tests/unit/runtime/test_cohort_rubric_status_summary.py +git commit -m "feat: expose cohort rubric status summary" +``` + +### Task 3: Frontend Contracts And Evaluation Selectors + +**Files:** +- Modify: `ergon-dashboard/src/generated/rest/contracts.ts` +- Modify: `ergon-dashboard/src/lib/contracts/rest.ts` +- Modify: `ergon-dashboard/src/lib/types.ts` +- Modify: `ergon-dashboard/src/lib/runState.ts` +- Create: `ergon-dashboard/src/features/evaluations/contracts.ts` +- Create: `ergon-dashboard/src/features/evaluations/status.ts` +- Create: `ergon-dashboard/src/features/evaluations/selectors.ts` +- Test: `ergon-dashboard/src/features/evaluations/selectors.test.ts` + +- [ ] **Step 1: Regenerate REST contracts** + +Run the repository's existing OpenAPI generation command. If the command is not documented, inspect `package.json` scripts and use the local script rather than hand-editing generated files. + +Expected: `src/generated/rest/contracts.ts` includes the new evaluation fields. + +- [ ] **Step 2: Write selector tests** + +Create `selectors.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { buildContainerEvaluationRollup, isEvaluationBearingTask } from "./selectors"; +import type { EvaluationRollup } from "./contracts"; +import type { TaskState, WorkflowRunState } from "@/lib/types"; + +function evaluation(status: "passed" | "failed" | "errored" | "skipped") { + return { + id: `evaluation-${status}`, + evaluatorName: "default", + totalScore: status === "passed" ? 1 : 0, + maxScore: 1, + normalizedScore: status === "passed" ? 1 : 0, + criterionResults: [{ id: `criterion-${status}`, status, score: status === "passed" ? 1 : 0, maxScore: 1 }], + }; +} + +it("detects tasks with direct evaluation evidence", () => { + const task = { id: "a", childIds: [] } as TaskState; + const state = { + evaluationsByTask: new Map([["a", evaluation("passed")]]), + } as unknown as WorkflowRunState; + + expect(isEvaluationBearingTask(state, task)).toBe(true); +}); + +it("rolls descendant evaluation failures up to a container", () => { + const state = { + tasks: new Map([ + ["root", { id: "root", childIds: ["a", "b"] }], + ["a", { id: "a", childIds: [] }], + ["b", { id: "b", childIds: [] }], + ]), + evaluationsByTask: new Map([ + ["a", evaluation("passed")], + ["b", evaluation("failed")], + ]), + } as unknown as WorkflowRunState; + + expect(buildContainerEvaluationRollup(state, "root").status).toBe("failing"); +}); +``` + +- [ ] **Step 3: Run selector tests and verify failure** + +Run: `cd ergon-dashboard && npm test -- features/evaluations/selectors.test.ts` + +Expected: failure because files/types are missing. + +- [ ] **Step 4: Add frontend evaluation contracts and status mapping** + +Create `contracts.ts`: + +```ts +export type EvalCriterionStatus = "passed" | "failed" | "errored" | "skipped"; +export type EvalRollupStatus = "passing" | "failing" | "errored" | "skipped" | "mixed"; +export type RubricStatusSummaryStatus = EvalRollupStatus | "none"; + +export interface EvaluationRollup { + status: EvalRollupStatus; + totalCriteria: number; + passed: number; + failed: number; + errored: number; + skipped: number; + normalizedScore: number | null; + maxScore: number | null; + evaluatorNames: string[]; + attachedTaskIds: string[]; + criterionStatuses: EvalCriterionStatus[]; +} +``` + +Create `status.ts`: + +```ts +import type { EvalCriterionStatus, EvalRollupStatus } from "./contracts"; + +export const EVALUATION_STATUS_LABEL: Record = { + passing: "Passing", + failing: "Failing", + errored: "Errored", + skipped: "Skipped", + mixed: "Mixed", +}; + +export const CRITERION_STATUS_LABEL: Record = { + passed: "Passed", + failed: "Failed", + errored: "Errored", + skipped: "Skipped", +}; + +export function evaluationStatusTone(status: EvalRollupStatus): string { + switch (status) { + case "passing": + return "oklch(0.70 0.13 155)"; + case "failing": + return "oklch(0.68 0.18 22)"; + case "errored": + return "oklch(0.62 0.18 35)"; + case "skipped": + return "oklch(0.65 0.03 250)"; + case "mixed": + return "oklch(0.72 0.12 85)"; + } +} +``` + +- [ ] **Step 5: Add frontend selectors** + +Create `selectors.ts`: + +```ts +import type { TaskEvaluationState, TaskState, WorkflowRunState } from "@/lib/types"; +import type { EvalRollupStatus, EvaluationRollup } from "./contracts"; + +export function isEvaluationBearingTask(state: WorkflowRunState, task: TaskState): boolean { + return buildContainerEvaluationRollup(state, task.id) !== null; +} + +function combineStatus(statuses: EvalRollupStatus[]): EvalRollupStatus { + if (statuses.includes("errored")) return "errored"; + if (statuses.includes("failing")) return "failing"; + if (statuses.includes("mixed")) return "mixed"; + if (statuses.includes("skipped") && statuses.includes("passing")) return "mixed"; + if (statuses.every((status) => status === "skipped")) return "skipped"; + if (statuses.every((status) => status === "passing")) return "passing"; + return "mixed"; +} + +function evaluationToRollup(evaluation: TaskEvaluationState | undefined): EvaluationRollup | null { + if (!evaluation) return null; + const statuses = evaluation.criterionResults.map((criterion) => criterion.status); + if (statuses.length === 0) return null; + const passed = statuses.filter((status) => status === "passed").length; + const failed = statuses.filter((status) => status === "failed").length; + const errored = statuses.filter((status) => status === "errored").length; + const skipped = statuses.filter((status) => status === "skipped").length; + return { + status: combineStatus( + statuses.map((status) => + status === "passed" ? "passing" : status === "failed" ? "failing" : status === "errored" ? "errored" : "skipped", + ), + ), + totalCriteria: statuses.length, + passed, + failed, + errored, + skipped, + normalizedScore: evaluation.normalizedScore, + maxScore: evaluation.maxScore, + evaluatorNames: [evaluation.evaluatorName], + attachedTaskIds: evaluation.taskId ? [evaluation.taskId] : [], + criterionStatuses: statuses, + }; +} + +export function buildContainerEvaluationRollup(state: WorkflowRunState, taskId: string): EvaluationRollup | null { + const task = state.tasks.get(taskId); + if (!task) return null; + + const direct = evaluationToRollup(state.evaluationsByTask.get(taskId)); + const childRollups = task.childIds.map((childId) => buildContainerEvaluationRollup(state, childId)); + const rollups = [direct, ...childRollups].filter( + (rollup): rollup is EvaluationRollup => rollup !== null, + ); + + if (rollups.length === 0) return null; + + const totalCriteria = rollups.reduce((sum, rollup) => sum + rollup.totalCriteria, 0); + const maxScore = rollups.reduce((sum, rollup) => sum + rollup.maxScore, 0); + const weightedScore = rollups.reduce( + (sum, rollup) => sum + rollup.normalizedScore * rollup.maxScore, + 0, + ); + + return { + status: combineStatus(rollups.map((rollup) => rollup.status)), + totalCriteria, + passed: rollups.reduce((sum, rollup) => sum + rollup.passed, 0), + failed: rollups.reduce((sum, rollup) => sum + rollup.failed, 0), + errored: rollups.reduce((sum, rollup) => sum + rollup.errored, 0), + skipped: rollups.reduce((sum, rollup) => sum + rollup.skipped, 0), + normalizedScore: weightedScore / maxScore, + maxScore, + evaluatorNames: Array.from(new Set(rollups.flatMap((rollup) => rollup.evaluatorNames))).sort(), + attachedTaskIds: Array.from(new Set(rollups.flatMap((rollup) => rollup.attachedTaskIds))).sort(), + criterionStatuses: rollups.flatMap((rollup) => rollup.criterionStatuses), + }; +} +``` + +- [ ] **Step 6: Normalize contracts and run state** + +In `rest.ts`, require the enriched existing evaluation fields (`criterionName`, `status`, `passed`, `weight`, `contribution`, `evaluatorName`, `aggregationRule`) to be present after contract generation. Normalize only genuinely nullable fields (`modelReasoning`, `skippedReason`, `feedback`, `evaluationInput`, `error`) to `null`. In `runState.ts`, continue deserializing `evaluationsByTask`; do not add `taskEvaluationRollups` or `runEvaluationRollup` to `WorkflowRunState`. + +- [ ] **Step 7: Run frontend tests** + +Run: `cd ergon-dashboard && npm test -- features/evaluations/selectors.test.ts` + +Expected: tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add ergon-dashboard/src/generated/rest/contracts.ts ergon-dashboard/src/lib/contracts/rest.ts ergon-dashboard/src/lib/types.ts ergon-dashboard/src/lib/runState.ts ergon-dashboard/src/features/evaluations/contracts.ts ergon-dashboard/src/features/evaluations/status.ts ergon-dashboard/src/features/evaluations/selectors.ts ergon-dashboard/src/features/evaluations/selectors.test.ts +git commit -m "feat: add frontend evaluation state domain" +``` + +### Task 4: Cohort Rubric Status Strips + +**Files:** +- Create: `ergon-dashboard/src/features/evaluations/components/CriterionStatusPip.tsx` +- Create: `ergon-dashboard/src/features/evaluations/components/RubricStatusStrip.tsx` +- Modify: `ergon-dashboard/src/components/cohorts/CohortDetailView.tsx` +- Test: `ergon-dashboard/tests/e2e/_shared/smoke.ts` + +- [ ] **Step 1: Add Playwright assertion first** + +In the cohort index test in `smoke.ts`, assert every run row has a strip: + +```ts +for (const { run_id } of cohort) { + await expect(page.getByTestId(`cohort-eval-strip-${run_id}`)).toBeVisible(); + await expect(page.locator(`[data-testid^="cohort-eval-pip-${run_id}-"]`).first()).toBeVisible(); +} +``` + +- [ ] **Step 2: Run Playwright smoke locally against an existing smoke stack** + +Run the narrow Playwright command used by the current E2E workflow for one benchmark. + +Expected: failure because the rubric status strip test IDs do not exist. + +- [ ] **Step 3: Create `CriterionStatusPip`** + +```tsx +import type { EvalCriterionStatus } from "@/features/evaluations/contracts"; +import { CRITERION_STATUS_LABEL, evaluationStatusTone } from "@/features/evaluations/status"; + +const rollupStatusByCriterion: Record[0]> = { + passed: "passing", + failed: "failing", + errored: "errored", + skipped: "skipped", +}; + +export function CriterionStatusPip({ + status, + testId, +}: { + status: EvalCriterionStatus; + testId?: string; +}) { + return ( + + ); +} +``` + +- [ ] **Step 4: Create `RubricStatusStrip`** + +```tsx +import type { CohortRunRow } from "@/lib/types"; +import { CriterionStatusPip } from "./CriterionStatusPip"; + +export function RubricStatusStrip({ + runId, + summary, +}: { + runId: string; + summary: CohortRunRow["rubric_status_summary"]; +}) { + const statuses = summary.criterion_statuses; + + return ( +

+ ); +} +``` + +- [ ] **Step 5: Render strip in cohort rows** + +In `CohortRunRowCard`, render: + +```tsx + +``` + +Place it under the cohort/run ID metadata so it is visible without widening the grid. + +- [ ] **Step 6: Run frontend and E2E checks** + +Run: + +```bash +cd ergon-dashboard && npm test -- features/evaluations/selectors.test.ts +``` + +Then run the narrow Playwright smoke command. + +Expected: selector tests pass and Playwright sees cohort rubric status strips. + +- [ ] **Step 7: Commit** + +```bash +git add ergon-dashboard/src/features/evaluations/components/CriterionStatusPip.tsx ergon-dashboard/src/features/evaluations/components/RubricStatusStrip.tsx ergon-dashboard/src/components/cohorts/CohortDetailView.tsx ergon-dashboard/tests/e2e/_shared/smoke.ts +git commit -m "feat: show cohort rubric status" +``` + +### Task 5: Graph Glyphs, Container Roll-Ups, And Evaluation Lens + +**Files:** +- Create: `ergon-dashboard/src/features/evaluations/components/EvaluationNodeGlyph.tsx` +- Create: `ergon-dashboard/src/features/evaluations/components/EvaluationRollupBadge.tsx` +- Create: `ergon-dashboard/src/features/evaluations/components/EvaluationLensToggle.tsx` +- Modify: `ergon-dashboard/src/components/dag/TaskNode.tsx` +- Modify: `ergon-dashboard/src/features/graph/components/LeafNode.tsx` +- Modify: `ergon-dashboard/src/features/graph/components/ContainerNode.tsx` +- Modify: `ergon-dashboard/src/components/dag/DAGCanvas.tsx` +- Test: `ergon-dashboard/tests/e2e/_shared/smoke.ts` + +- [ ] **Step 1: Add Playwright graph assertions first** + +In `assertRunWorkspace`, after selecting an evaluated task: + +```ts +if (evaluatedTaskIds.has(selected.id)) { + await expect(page.getByTestId(`graph-eval-glyph-${selected.id}`)).toBeVisible(); +} +await expect(page.getByTestId("graph-eval-lens-toggle")).toBeVisible(); +await page.getByTestId("graph-eval-lens-toggle").click(); +await expect(page.getByTestId("graph-canvas")).toHaveAttribute("data-eval-lens", "on"); +``` + +- [ ] **Step 2: Run Playwright and verify failure** + +Expected: missing glyph/toggle test IDs. + +- [ ] **Step 3: Create graph evaluation components** + +`EvaluationNodeGlyph.tsx`: + +```tsx +import type { EvaluationRollup } from "@/features/evaluations/contracts"; +import { EVALUATION_STATUS_LABEL, evaluationStatusTone } from "@/features/evaluations/status"; + +export function EvaluationNodeGlyph({ + taskId, + rollup, +}: { + taskId: string; + rollup: EvaluationRollup; +}) { + return ( + + ◇ + + ); +} +``` + +`EvaluationRollupBadge.tsx`: + +```tsx +import type { EvaluationRollup } from "@/features/evaluations/contracts"; +import { EVALUATION_STATUS_LABEL, evaluationStatusTone } from "@/features/evaluations/status"; + +export function EvaluationRollupBadge({ + taskId, + rollup, +}: { + taskId: string; + rollup: EvaluationRollup; +}) { + return ( + + {EVALUATION_STATUS_LABEL[rollup.status]} · {rollup.totalCriteria} + + ); +} +``` + +`EvaluationLensToggle.tsx`: + +```tsx +export function EvaluationLensToggle({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: () => void; +}) { + return ( + + ); +} +``` + +- [ ] **Step 4: Pass roll-ups through React Flow node data** + +Extend `TaskNodeData`: + +```ts +evaluationRollup?: EvaluationRollup; +evalLensEnabled?: boolean; +``` + +When building React Flow nodes in `DAGCanvas.tsx`, set: + +```ts +const evaluationRollup = buildContainerEvaluationRollup(runState, task.id); +const evalBearing = evaluationRollup !== null; +data: { + task, + evaluationRollup, + evalLensEnabled, + dimmed: evalLensEnabled ? !evalBearing : isSearchDimmed, +} +``` + +- [ ] **Step 5: Render glyphs and roll-ups in nodes** + +In `LeafNode.tsx`, render `EvaluationNodeGlyph` near the title for direct task evaluations and `EvaluationRollupBadge` if there are multiple criteria. + +In `ContainerNode.tsx`, render `EvaluationRollupBadge` in the header row next to the child count. + +- [ ] **Step 6: Add lens toggle to DAG controls** + +In `DAGCanvas.tsx`, keep: + +```ts +const [evalLensEnabled, setEvalLensEnabled] = useState(false); +``` + +Render `EvaluationLensToggle` in the floating control card area and set: + +```tsx +
+``` + +- [ ] **Step 7: Run focused frontend tests and Playwright** + +Run: + +```bash +cd ergon-dashboard && npm test -- features/evaluations/selectors.test.ts +``` + +Run the narrow Playwright smoke command. + +Expected: graph glyph and lens assertions pass. + +- [ ] **Step 8: Commit** + +```bash +git add ergon-dashboard/src/features/evaluations/components/EvaluationNodeGlyph.tsx ergon-dashboard/src/features/evaluations/components/EvaluationRollupBadge.tsx ergon-dashboard/src/features/evaluations/components/EvaluationLensToggle.tsx ergon-dashboard/src/components/dag/TaskNode.tsx ergon-dashboard/src/features/graph/components/LeafNode.tsx ergon-dashboard/src/features/graph/components/ContainerNode.tsx ergon-dashboard/src/components/dag/DAGCanvas.tsx ergon-dashboard/tests/e2e/_shared/smoke.ts +git commit -m "feat: add evaluation graph lens" +``` + +### Task 6: Rich Evaluation Workspace Panel + +**Files:** +- Create: `ergon-dashboard/src/features/evaluations/components/EvaluationCriterionCard.tsx` +- Create: `ergon-dashboard/src/features/evaluations/components/EvaluationMetadataSummary.tsx` +- Modify: `ergon-dashboard/src/components/panels/EvaluationPanel.tsx` +- Test: `ergon-dashboard/tests/e2e/_shared/smoke.ts` + +- [ ] **Step 1: Add Playwright drawer assertions first** + +In `assertRunWorkspace`, inside the evaluation tab branch for evaluated tasks: + +```ts +await expect(page.getByTestId("workspace-evaluation-metadata")).toBeVisible(); +await expect(page.locator('[data-testid^="workspace-evaluation-criterion-"]').first()).toBeVisible(); +await expect(page.locator('[data-testid^="workspace-evaluation-criterion-status-"]').first()).toBeVisible(); +await expect(page.locator('[data-testid^="workspace-evaluation-input-"]').first()).toBeVisible(); +``` + +- [ ] **Step 2: Run Playwright and verify failure** + +Expected: metadata and criterion card test IDs missing. + +- [ ] **Step 3: Create `EvaluationMetadataSummary`** + +```tsx +import type { TaskEvaluationState } from "@/lib/types"; + +export function EvaluationMetadataSummary({ evaluation }: { evaluation: TaskEvaluationState }) { + return ( +
+
+
+
Evaluator
+
{evaluation.evaluatorName}
+
+
+
Aggregation
+
{evaluation.aggregationRule}
+
+
+
Score
+
+ {evaluation.totalScore.toFixed(2)} / {evaluation.maxScore.toFixed(2)} +
+
+
+
Stages
+
+ {evaluation.stagesPassed} / {evaluation.stagesEvaluated} passed +
+
+
+
+ ); +} +``` + +- [ ] **Step 4: Create `EvaluationCriterionCard`** + +```tsx +import type { EvaluationCriterionState } from "@/lib/types"; +import { CRITERION_STATUS_LABEL, evaluationStatusTone } from "@/features/evaluations/status"; + +export function EvaluationCriterionCard({ criterion }: { criterion: EvaluationCriterionState }) { + const tone = evaluationStatusTone( + criterion.status === "passed" + ? "passing" + : criterion.status === "failed" + ? "failing" + : criterion.status === "errored" + ? "errored" + : "skipped", + ); + + return ( +
+
+
+

{criterion.criterionDescription}

+
+ {criterion.stageName} · weight {criterion.weight.toFixed(2)} · contribution {criterion.contribution.toFixed(2)} +
+
+ + {CRITERION_STATUS_LABEL[criterion.status]} + +
+ + {criterion.evaluationInput && ( +
+ {criterion.evaluationInput} +
+ )} + + {criterion.feedback &&

{criterion.feedback}

} + + {criterion.modelReasoning && ( +
+ {criterion.modelReasoning} +
+ )} + + {criterion.skippedReason &&

{criterion.skippedReason}

} + + {criterion.error && ( +
+          {JSON.stringify(criterion.error, null, 2)}
+        
+ )} +
+ ); +} +``` + +- [ ] **Step 5: Replace the current criterion map in `EvaluationPanel`** + +Keep existing empty state behavior, but render: + +```tsx + +
+ {evaluation.criterionResults.map((criterion) => ( + + ))} +
+``` + +- [ ] **Step 6: Run frontend and E2E checks** + +Run: + +```bash +cd ergon-dashboard && npm test -- features/evaluations/selectors.test.ts +``` + +Run the narrow Playwright smoke command. + +Expected: evaluation workspace assertions pass. + +- [ ] **Step 7: Commit** + +```bash +git add ergon-dashboard/src/features/evaluations/components/EvaluationCriterionCard.tsx ergon-dashboard/src/features/evaluations/components/EvaluationMetadataSummary.tsx ergon-dashboard/src/components/panels/EvaluationPanel.tsx ergon-dashboard/tests/e2e/_shared/smoke.ts +git commit -m "feat: enrich evaluation workspace panel" +``` + +### Task 7: End-To-End Hardening + +**Files:** +- Modify: `ergon-dashboard/tests/helpers/backendHarnessClient.ts` +- Modify: `ergon-dashboard/tests/e2e/_shared/smoke.ts` +- Modify: `tests/e2e/_asserts.py` +- Modify: `docs/architecture/07_testing.md` + +- [ ] **Step 1: Expand backend harness TypeScript DTO** + +In `backendHarnessClient.ts`, add: + +```ts +export interface BackendEvaluationRollup { + status: "passing" | "failing" | "errored" | "skipped" | "mixed" | "none" | string; + total_criteria: number; + passed: number; + failed: number; + errored: number; + skipped: number; +} +``` + +Extend `BackendRunState`: + +```ts +rubric_status_summary: BackendEvaluationRollup; +evaluations: { + task_id: string; + task_slug: string | null; + score: number; + reason: string; + evaluator_name: string | null; + criterion_statuses: string[]; +}[]; +``` + +- [ ] **Step 2: Add backend E2E assertions** + +In `tests/e2e/_asserts.py`, assert happy runs expose: + +```python +assert len(root_evaluations) == 2 +assert {ev.parsed_summary().evaluator_name for ev in root_evaluations} >= {"default", "post-root"} +assert all( + cr.status == "passed" + for ev in root_evaluations + for cr in ev.parsed_summary().criterion_results +) +``` + +For sad runs, assert failed or skipped criterion state is exposed when a criterion does not pass. + +- [ ] **Step 3: Add UI assertions for each feature** + +In `smoke.ts`, assert: + +```ts +expect(state.rubric_status_summary.total_criteria).toBeGreaterThan(0); +await expect(page.getByTestId("graph-eval-lens-toggle")).toBeVisible(); +await expect(page.locator('[data-testid^="workspace-evaluation-criterion-"]').first()).toBeVisible(); +``` + +For happy runs: + +```ts +expect(state.rubric_status_summary.status).toBe("passing"); +``` + +For sad runs: + +```ts +expect(["failing", "errored", "mixed", "skipped"]).toContain(state.rubric_status_summary.status); +``` + +- [ ] **Step 4: Update testing docs** + +In `docs/architecture/07_testing.md`, add the frontend evaluation visibility surface to the E2E assertion table: + +```text +Evaluation visibility | Cohort pips, graph glyphs, container roll-ups, eval lens, workspace criterion cards | Playwright + backend harness DTO +``` + +- [ ] **Step 5: Run focused checks** + +Run: + +```bash +pytest tests/unit/runtime/test_evaluation_summary_contracts.py tests/unit/runtime/test_cohort_rubric_status_summary.py -q +cd ergon-dashboard && npm test -- features/evaluations/selectors.test.ts +``` + +Run the benchmark E2E smoke workflow locally for one benchmark if the stack is already available. + +Expected: unit and frontend tests pass; Playwright passes for the exercised benchmark. + +- [ ] **Step 6: Commit** + +```bash +git add ergon-dashboard/tests/helpers/backendHarnessClient.ts ergon-dashboard/tests/e2e/_shared/smoke.ts tests/e2e/_asserts.py docs/architecture/07_testing.md +git commit -m "test: cover evaluation visibility e2e" +``` + +--- + +## Rollout Notes + +1. Backend changes are additive and can ship before frontend rendering. +2. Generated REST contracts must be refreshed after backend DTO changes and before frontend contract normalization. +3. Cohort roll-ups intentionally stay lightweight to avoid loading full run snapshots for every row. +4. The evaluation lens is local UI state; it should not change the URL in the first implementation. +5. If skipped criteria require semantics not available in `summary_json`, extend `CriterionExecutor` to emit explicit skipped results in a later follow-up rather than inferring skipped state from missing rows. + +## Verification Matrix + +- Backend unit: `pytest tests/unit/runtime/test_evaluation_summary_contracts.py -q` +- Backend unit: `pytest tests/unit/runtime/test_cohort_rubric_status_summary.py -q` +- Frontend unit: `cd ergon-dashboard && npm test -- features/evaluations/selectors.test.ts` +- E2E: run the existing canonical smoke command for at least one happy/sad cohort. +- Lints: use `ReadLints` for edited files after each frontend and backend slice. + +## Self-Review + +- Spec coverage: cohort pips are covered in Task 4; graph glyphs, container roll-ups, and eval lens are covered in Task 5; richer drawer metadata and criterion detail are covered in Task 6; backend schemas/endpoints are covered in Tasks 1 and 2; E2E coverage is covered in Task 7. +- Placeholder scan: the plan contains concrete fields, commands, file paths, test IDs, and code shapes. Follow-up notes are explicitly scoped to future semantics rather than missing implementation steps. +- Type consistency: `EvalCriterionStatus`, `EvalRollupStatus`, `CohortRubricStatusSummaryDto`, and frontend-only `EvaluationRollup` names are used consistently across backend, frontend contracts, selectors, and components. diff --git a/ergon-dashboard/src/components/cohorts/CohortDetailView.tsx b/ergon-dashboard/src/components/cohorts/CohortDetailView.tsx index c071391d..ffb485b5 100644 --- a/ergon-dashboard/src/components/cohorts/CohortDetailView.tsx +++ b/ergon-dashboard/src/components/cohorts/CohortDetailView.tsx @@ -292,13 +292,58 @@ function RunDistribution({ cohortId, runs }: { cohortId: string; runs: CohortRun /* Run Row */ /* ────────────────────────────────────────────────────────── */ +function rubricPipClass(status: string): string { + switch (status) { + case "passed": + return "bg-emerald-500"; + case "failed": + return "bg-rose-500"; + case "errored": + return "bg-amber-500"; + case "skipped": + return "bg-slate-300"; + default: + return "bg-[var(--line-strong)]"; + } +} + +function RubricStatusPips({ run }: { run: CohortRunRow }) { + const summary = run.rubric_status_summary; + const label = + summary.total_criteria === 0 + ? "No rubric results" + : `${summary.status}: ${summary.passed} passed, ${summary.failed} failed, ${summary.errored} errored, ${summary.skipped} skipped`; + + return ( +
+
Rubric
+ {summary.total_criteria === 0 ? ( +
No rubric
+ ) : ( +
+ {summary.criterion_statuses.map((status, index) => ( + + ))} + + {summary.status} + +
+ )} +
+ ); +} + function CohortRunRowCard({ cohortId, run }: { cohortId: string; run: CohortRunRow }) { const started = formatStartedAt(run.started_at); return (
@@ -352,6 +397,7 @@ function CohortRunRowCard({ cohortId, run }: { cohortId: string; run: CohortRunR {formatScore(run.final_score)}
+ ); } diff --git a/ergon-dashboard/src/components/dag/DAGCanvas.tsx b/ergon-dashboard/src/components/dag/DAGCanvas.tsx index ef653852..10da6eae 100644 --- a/ergon-dashboard/src/components/dag/DAGCanvas.tsx +++ b/ergon-dashboard/src/components/dag/DAGCanvas.tsx @@ -28,6 +28,7 @@ import "@xyflow/react/dist/style.css"; import { TaskStatus, type WorkflowRunState } from "@/lib/types"; import { nodeTypes, type TaskNodeType } from "./TaskNode"; import { GraphDependencyEdge } from "./edges/GraphDependencyEdge"; +import { buildContainerEvaluationRollup } from "@/features/evaluation/selectors"; import { GraphExpansionProvider } from "@/features/graph/hooks/useGraphExpansion"; import { computeHierarchicalLayout, calculateExpandedContainers } from "@/features/graph/layout/hierarchicalLayout"; import { DEFAULT_EXPANDED_DEPTH } from "@/features/graph/layout/layoutTypes"; @@ -175,6 +176,36 @@ function SearchCard({ ); } +function EvaluationLensCard({ + active, + count, + onToggle, +}: { + active: boolean; + count: number; + onToggle: () => void; +}) { + return ( + + ); +} + const LEGEND_ITEMS: { status: string; label: string; cssVar: string }[] = [ { status: "completed", label: "completed", cssVar: "var(--status-completed)" }, { status: "running", label: "running", cssVar: "var(--status-running)" }, @@ -220,6 +251,7 @@ function DAGCanvasInner({ const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [containerDims, setContainerDims] = useState>(new Map()); const [prevTaskIds, setPrevTaskIds] = useState>(new Set()); + const [evaluationLensActive, setEvaluationLensActive] = useState(false); const { fitView: rfFitView } = useReactFlow(); const fitViewTimer = useRef | null>(null); @@ -281,6 +313,20 @@ function DAGCanvasInner({ return count; }, [searchQuery, runState?.tasks]); + const evaluationRollups = useMemo(() => { + const rollups = new Map>(); + if (!runState?.tasks) return rollups; + for (const taskId of runState.tasks.keys()) { + rollups.set(taskId, buildContainerEvaluationRollup(runState, taskId)); + } + return rollups; + }, [runState]); + + const evaluationBearingCount = useMemo( + () => Array.from(evaluationRollups.values()).filter((rollup) => rollup !== null).length, + [evaluationRollups], + ); + useEffect(() => { if (!runState?.tasks || runState.tasks.size === 0) return; @@ -293,6 +339,8 @@ function DAGCanvasInner({ "LR", newNodeIds, highlightedTaskIds, + evaluationRollups, + evaluationLensActive, ); setNodes(result.nodes as TaskNodeType[]); @@ -311,6 +359,8 @@ function DAGCanvasInner({ selectedTaskId, newNodeIds, highlightedTaskIds, + evaluationRollups, + evaluationLensActive, setNodes, setEdges, rfFitView, @@ -486,6 +536,11 @@ function DAGCanvasInner({ onSearchChange={handleSearchChange} matchCount={matchCount} /> + setEvaluationLensActive((active) => !active)} + />

54hW>zaU@iW0 za0<>|%jzRJxgV0Jt;>D#!P`(Ull>05~P<@!VF#`=m0s-w*P zS?()1JBLUIjuI0ss*<*s5k2pf6BkuKP3!Q`uWPWF5}9-(Wt(Ko8)zw;Mb{Ud7dZ%V zRpwiNaB&m*`Hl1)B@V|4bk_eN>rStG3?8mnpkg+HgHnQsh%OUp6IzvgQpo;3%qL!q z;PUqg6eWDs8CXcZ@}Aa*psZUOt3)9WDzCt)w&;Adw-r2gfK+GtBQ_m}asWKL_IW98 z?$iJ+Bz1_Ij0~mb9>5+zLpZu{J#Hf}{yrkDean;bWdO9%oz6rHG>u}-rYKeDBghkW z2e41~_xmb8N+v8(W+n3vgWw1&Xt#2w)j8~{Ds6<+19>w+H+szbe0*Y7j-}yN zIEnZj+JDEEVT9KbPp>fOyK+^Sd3wpE6)DFupbA-MR98RTAsHA@UPaQTbZu?1;x{(g z-Dq%hAQzE7I$i9H!b?PoLD)2 z)FU+_sO6sK9o>dpoy~Ut9{YG$Zu>xExL)7&kH{w#i-%Qbd<4%eN-2fx3>mQ>BZYc2 z#e;m_E8h_ZUSqhqlV4Spno7-E*YC6nx0xfMnGnC6Tv5SCE`*ZL$=YI+;r~n)ze^}5 z{42+@nN~8w!MK|vUj@IXqv1^xk@-iW_ss*tF9#ASMWj}L`Ey4$)&YUeT=2!W_n_E)+ zR4uqTW_)3`{Vg=u?G~BB1| zjlJQ`PHl1lP2MloSJqYiKinv%(!e7QSDccsv6`LvMX2*NLqP=>-o* zPec>`IZJ(-J$c73<$KMXZ7`DA_EhM&_eOW`_=o%nlVYOil_SNbM5)u3_8R=^E4zbi zRYw~fM@9uH?xWbH7&uaVzO4E`Bk_G<(@(&=d78K>vb!oQa957b~zLb+w-ECn_; zWaB%fSRR%IB?Gd2Ke}IybSiVDmOY4L_LXWdHEvWnhd^?SD}(vCuzK#ME@}+U`}=4K z02FEpX)^?E)6^N`&kNeNdB-;d+TY)cdTmgt?MY`$?r_@uRuhRNNRCwz=E%3{6i}ji z7GWq+<8damfazhf5F_bzb&Fm_1)0_DBx*CLD~4J@Ufr?|oCY&rW@(+sL|J(5$Q_Jz zL=+aqdtWz#2DQxn*=JjFpP1uTDj{gR&_de~Tf>}V|E;P}AZaCqhr~L%cyPFWQ!@EY59#yqAnXh+W{=PP&xJFL2@>_FA z4nhSCgPsjqh@0Bh>cxZ_!fRzZmUBa3K%~E@*O?7IBWd7FNs8E zHPL27m|mhdI2+vMV+%;+aXZI&@%gALSE;O7Ul$x`h}BNr2J?68_7Ebq)lWJn7Glbj zCOOsVoR40r+ZeU@r74@3d!BD6mfWODE&U?Wou29VCx@a)zvgnX(|ghJ6KaC6k&F)( z6ycqd@*Qo9&Tj%J32MO~M%SM;d3m0m5Q8H1G<@vde3y_A!57{k@!MrP0?EsijD*s4 z@zClwQD$hYI)4DGfBu1)fcW@$FosE^I#o+ljNnwOY%6hagq+3#|G zR8(kelo;R86Rfq;Fz{#P5T)k8VfrJg#ZY5&{Pz1xk|*oOS9P)>I%F^SvH^J?oKXXZ zD0j75a*6vBKfwq~rGChRff*mP^gqCrq*xrLZ0qam+xZmPmr_W>&wZ04IG);rMHBGA zL?HYEdg^wpR~^1qrQP@-Wno$erq@t`zHGlcvucJ@-4qtx;^oof_!8F3_+&V zGV5$7!T23KIDNWFSoy&Wtr;5}`H3##gRcx83FD z@`qX#RzJ&I*e2q663&4Te~XdzGOft8Mvl75&8>q2y%M8#n7G`V1juo0Oo*Lb;nvC7 z;36N}a$=d|q`pz2cHGdPxTVQ=Kt!5Z&pn?S(oqP{ok&q`zmT&1?pbGDq}WMG9r7~d zd3=YaMS)JYM!qT6{R^g9%yY*tC%L8}Ws$v$m3zb<`gV=`fqZ#z41k6u>BmhQF^(8@ z+5)u`k2|dxpGx#&tySxe7K6kdl+~3493Q{^s2vy$$d!+`xMtwyf^R*EeVX?X%YFnJDzq$5{*H1N z(Pc?VLo$z=*y}5sK+(|$Is#18{cvwP19~ror=8}l`2)=XNPg=B^`xb^823V{+nQ8< z_GVf+O0XES?4x62AIZ~+jq$Ie%qnk@=RAhk_(mY06q5lwE#Pr}3Q2fKZ^jfQw`VhFp>5V0 zT_G_?M$@TYSrEuleN;2;E9AVF1qAdJ8#EB0AZ+mk-T@e5thceed<&(WgBqtf(5{e<(Y z6V>)tcJbcC##M;D^Lg9qz&_+mHyfOY#l=2|d-a-U`$W^c;%LBkM z1sM>e?6y*Kr%>?->^z*D1?|JZyyG4Zk2=s5Ao=%BAwEuYa3uqDxoQ$kTI3z6h+FgK ztNiK!cqpEE!zc>#cFJULZ?VO2fwgY1PEJskK9DtqoWU1$zG_xgSvgdQnEYA-ZUFnQ zQ%AvlSITYVEGxkjuyA_BZr?Y-DG@8<1NCqfh`fMHse7T=O!^V!r1;Pt=QW8P#q}F; z(Pz_JnBw#g|6PUmIWTA)fFr<*U@}>@!L`xXtEf9$!Dse+a(4O$jQ!?994Pim z6^I5=>zUS~>}SNq(w9j(dQtnQvW!|)*7U}nYeP;g-J;g6i>juXsb&douE&fS_RXw! z%y2N>h&i8DzsVIL#Vw3-ps>5;W4V*PYKO9aBEQ;DhT$6moz5Yk5dal|TR&n{OLyX9 zIbJy>b}>M3D=`2W2kr;h=E<7LugF>8W5veBbqZwFAo!DL`e1_s=P}x;ntwz&K;I2F z#dwr;espxiY#O~h8mPlpgEat}K*WzZ%F!F6MMj#*?i`NT%7Q8fHZ{r;ea?Zt8G$DN zMFB(NJ%S-wD_151K>~FrY9E)wLNc|l!~rkx%Z+jhyU)!qZdo8Fe8-WfO)~f`(+q%5 zxg6zruBiyzSy<8qhZtQ0_XjrzGP7Wqll3WulBE@QkX)a*{3kpxxuz{A=T}4$14OU| zCMMa>M9oWhs8^Xd03Hwx^vWAzY3Ay_ph>yIs>5Rtfh=QzG2}5;M9yT!#pu;seGxky z{$$BKwrth<{%qk+x5@gGt%YW<&EJN&IV!rHMji3xFN(2XcHb!Bb z0W=>QK-@ID)rx}w+|QsrGmywZ#VzZ7%~MU{;UP^w%wuN*;;d=Z7+!WoaVAnC>p)aH z@*bd*8OIL?i*UsBJ{i(gf{pZDO(Qp=q{ttvMgw@`gwEwbGl8^BIILimVf`H==T5j=I@7!z=D`2&bRoBszer-FzgOO6!o(O?NS0)6xz5wL9g zat}YvTW%ko|F5pc)=p>^YxXtbX4WWw)`W3K7+b(@hN9^PPX==|y%BObVAqY~HXSro zBgp$=G5iLW3Y>5``t>3c1U0g-?|gqUM4m@dOh?Y^7&%uxaT)rS8qM+68U4`uZfrc# z-deRp&ABTw&EA(_q=8MW5l&J~u*}iPvulx};=_KlcXo)}~ zxvog$M;Mi~@xGXeJuFPDcR6EM@_ZSoS!t3VGLO}>Ei&9TT3zNoSB=&xZMjmhuAgbV zd1#dY`0MLek=bI%65yZc#tnQ~JUk*@Iy_I$%#De0@}!&J(v^~!@0tnO8eqj| zxWpNMzH&MFRL5JApD+;re)-E8;YTWijv81fqgZ&xyvn0R^@BMME>Y8mfi zMGze80%UH#LXF>oWg%Ag0E}$Tlqw~c<=rk*fEbHHr9|b6lpohh2$A(??L$mG3=vG- z-q>?5Z|`j?mUVOWe)(`_&-cpRd{301EZM4T-uw(n&tGIWZl=j{b$TS;4DUv<(p_7O zOrbiwg865xyIV=10t@5)}Pchlz3 zL)0X(u$CTSvj85@g13AZ?0fb!td5(PQjA~GTyb5nXQG>#b!?OSg>{q7D($$`Uui848`I-qoc zWX985e9+zlTnh7FbO!=6+VU_`H03F$UYxot&la4UV!D1^3OY1!go4@sZ{XO&xO%>g z(#(x^N2-R2@QUo~JXc@=B8J}LgaXVD-wRIBK(cfj+E+277JU(B5a2Mc`+N-UZx znEPa}ReLWG#lGG7?FoX213bNws;5s;HgkhlCt2RoL{5_KjSl>i>UF7*l z?uFqSu4>;1^xbV}f!fwW7pGCo{a{~*8#+bfw!P?{lurGxK^qUW7w^9Jplk0=XR-_v zS#5u5p1(*XhZUGN8W^5)7iyWt7f{Q9F;E7Ky^o#*j{R(Io(GnAccLg5wmWn&SPbU) z7WO21oD1}EkA8FpA4+S=1ek(ffE*0?>z_eBz>1qC4}M>SlZl4Hq_VBfNpRYt<5J!d zbw7on)Rwdx_#|{hJST>a4?IJu2iFK@L8`413-eD=R{oN>qVER&>=2k1_i^O99ku!? z5Gq-z`!Y#wYKkuK%#*&pe~EGgRQB)FZ5bbbZvQxniHf!xqF;n$LswOk%9NDJ-wRv9GwQY*Bx6=}? zWm_OVk{uilQQ$g>??_^*alK;-1z|%ju&;u*JyzQshqqLuybyf1sioKKJv=V7hrRW8 zA)#%k0mf0==O)V+j~X+tl*?J#z@$O>X;UT|iR37k1OOjksQpcCRtwL= zs@N%u(PPCFL8!vr3v8Q!9lLG3N54BW4`Ensv&b1Xej=KT=AJfr$W~0{F8Qh%_Hs-F_E zTxneby)-mvIJTg z-`hMXqIqDz>0YMQ?KSh6Z6hQm_Gu(KxE1y|KO(8!KZ$^a6xUum55!^tt8{)zWk6;I z{#_&a%XMW9t*fI<2^i=d(X&>3AZ73t4v3<>dDD*{|22+f(2jukN~KvZUH<&`2FhLG z&RaF`O_uzIy!`{`T4*t@2BdD8ui2;nfyh;r6F)zOU*=U&;*YyonK*v{hWzd&s=~K+ z17hDOn@&TN{hha5QLrDFzJ0p|Fln(6D|WOurOI^|fOGYVUR+j9gs);Nnt}iFJ=f0=uPD9~ip?}D)0Yh7GBOYbW8RVi#Z7F8E$%%r})jQ{p?%~`?#qu8_5 z#&$OVDq= zS_Z>0`-dI&7*<7x%1g&U{+^fI-r$jX2=HYq&zvN4;&k#gx6rLoF|YKo+8kP~bXO@< zO@(oz-VZFsh+?O(rXy2RQ{{9}Yglw`5zNUDw`K#eepDa8qk~QEIF0zQ9d{1yUd_)i zQyvoYY%fZj+rLm^y=+jkX;o9NbK=prRSLJhbrXAnIPZqd7O^AUv02S#ArK%|qA>{O zW4C|%i`oKI1k|o#SiFLLgVH4Y@VR3pfCD?yr3hIemk$Q#*IB}WVY~nwrX!!FrSVgs!-GFzG8scQEMv^t zO9>*O=#+xCOK_1uOJ1Lu$&_;{=Qg9uKcv@%>GhS^LagrvB*?2aLCk~ljJPeWD&-Z5 z8toYTR%iQPaA9DtQ7%U=jszGHQBehVi@x#AkQDr&NH^N_hxU6bI}Z=Br-N$Wo2EtJ zCUt7oW>%vLyvfa z%G2+CEh-X2YKI7?D0SH}M-FV!KgYtuj^Rzp>4^_HTIX~@V+gIT{sZkioUaj0o})Kr z;0Y1EIJJS^9%ZDU+5?*Kw^IOJ1kgl4%XNHobZ1XBSW1eEA)Um?BvSg;uitG=Rw13~ zOYSMlshW|L3DpnL(PcuC)V``eAo`@ViSq$SPq-JtN{8G6ud(fpR!}yz*BSm7I6boU z+RvxTYllhj*+AH1NqC3N!a&uM){eNNa*jI7c;}s3(o$bvX{^03 znn~a078jY&r4^hIO8B3av}yeP#GylnvhBsc7b;Fo z5?_To`hRxOIQa-anh(ivGXe2urp4iAm1ONO-R#-?6M^~yCKzF-d%>YMR_js$g#ZS) z>AImhO(R2S&`}A};qEcfvHQSmW9+)0>24)1PxtU)wL00I_o-&Uy@nT39C*a?Xt07D zY;DI%j00;djEx^a@E6SSQ4urp^Pe`a1Eb=jkP^OUdP4*U(lef_%6p1D~j zZC@bhw?QJJqwK5&d|TMW{xPwplmCrM#6Wp+lVJmYJ7rGwi6n|@{qHNC;XiY2R5T>~ zUw>cWU;eYrCIfaMildKu{m;hxzc;H~|3CKMf5Vzl&hjZi|FOxt{{JCn|6fk{f0pT} zkYAOSZ~vI3qpk$AX=nX>&4-aO14vi=GqEu-sD$ej zB~SL7No4fQ`3)}M@Lvlg;gQkNnCNIkQ=g46d;z31mEPW0Sruo9wqaL;A_eG_%i5?< zJ2G+$+TZX;sFlQUt?w&j3jKyC-dLV&IK*Kb1cERzkxUSkiu`}Fd?kVrHS7NvDbqx1 z6}|#GI50xN{b?2A0RVA$bp~TK^`g&J=0B? zYa2pUURzsR{Q5{7+=T32n1?RHN0NWDoJM1u&`!tPgIA6TR>LTo{vL~1KU&2?Oeu!E z%h=eMJ2(`9xppTdAFzz!9a#2c}GOJRn~)wE49JRu|5wM=jNDSJ0~u6_`%r z*%@;mHI>_0?B+B*Sn9o+oK(^U>ZVX!aQvoJBp#D~1L_V&9*G`R36V^_xQ`!iklzEP zO|(oJpFx8Uum#%_8#+*gvShNL+xet->~{(xVGpOjP5Iyzajc9r?k{V=30wQkANYiw z|6MK|uv`GnmlnH)kN=O9LxhBzrpqXy;7S9S)6aa73{%%o4u;ojB-Jqk z_v3v{hHcb?t}0fAiIK`?0Slg=kWE$}^-%G!!Sjv8WaNHoM6%pgXPD?JhUn`C4fpo- z*JEE*G;KnyBG`4TM~VzksYI-P0)X+zPS``J2q1ARl74hrjY_l+2Rwv3!VqWhxOrc0+#o4{nb0c^9jIjTt1%uJ zTL_8&y-|Hv1QeVhDdbu@gIUa2wFBOI%GbNBcjELQ|8_X`Vq^T_yozLd6esnY-{lsE zP-?;kpQ5BM$RY>(xzU%lSD$jfRr7Vj#`YYT^EYq9TrWWnG5=l#jLd*H4^@6^V?zUYenLDL8xLBlPXT%;aSP=gQAp=X3j@Xp<>hI3 zc!7uG0)aqa(hZ9**MB_>_Bw*yg0<$t4Si~xGu{7v2tfd!W%PcL-5^9q_tz#ZDk^G& z%?Ngp_) zX-3x=9SGc6+J)x=SEBpsMTUmYM@pwip6?*@Dr1XLaH zd?;m_;XZ<(M{~I%A!&cmZ#06`T)g%y~wAmauQg4aGG(EN)BY! z)zEFsXyVQqY@P3;t|1p3P$=aaRQ0c6ECiJ;K*+&qZ=o!KrvjC1^~?ke0?mpc{Sc&N z)sUZQ<_410u7-w}u%ppU_xNw(Y* zpYHztUD$j;QU*K3P0X}Ee+#LnCj#EeW*-=VTMV3sozV#P7y5!)RDbd!c{5D@iH6ol z77rWj6EvlA;Zd=~CgziLVNlDkb=v=q%ds+*coeQt@Qmzu2%aibg5!Ru=HwP!A#g3* z2T43A4!)1Wf@OLm>@0C6cUn>JIo7{QSJPD#6BCtgOpA46r2O}TXj*|fje7NI-!21Sg(2IpQtG7>~ z5CGy;3Iih{ZhA9V)s&m}FTf}uS{ovi3Bizp#|~_#l%Z;XF9OTI4r&4nYcDed_(_c^;&ZpK*MpVIoIg0t}vY((s&o9+m&$P9Z#Ag?SvJ-xJa7rKp7qxKR2{P)8scrBwLl(R%nXl`!K$iyVF zXP?JnsH?N{v0KSF>;NjqZreZK8)BwWcYN)lF@Ka zh4XEdxy<|0;4GtQ3wJ=#57WG763_R+SZWkprhY-&zEKYOO$-&b_Y|B@{H`k)+!*f! z#gbkc2E`57+4fi-|5Z?cz?#y09j8Gu^9|MyvDBGqdlFnN-K-HZmK56Cei2pW6f1`I% zH3H&g@#cs$Qtyfcu%@3v^X;drBrlOuVc3dm6qB!8-dAgM_r`7PBGi3Dy;R7X{uTd< zSigG*qLg;PmC0`1LZzp`hzhkiqh8Cn|Hz3`lx~I9z@|OkX#|zWk}2@4;3?pK@ZdFM zi4++$LZ_$FQgmpx$BdCrp#^^3(8*r?@2#j*Ih3WROAMIZYviP9HQRuo4E#%BdvlFs zf2T)m03RBDz7ynip}vzdt^Xs_f7)U6|9ulcUVNs*Sv6QEjOES#hNHoLeq!`r4{SbJ z6O20jQLq2?z+?*kH~-)Gsy@O0H@OP+8%mL3{PW#W@9F5_R{+gEL~{MtYadY6GV}K^ z!`Iwi{ZpEv8YyF(7L;Zg(I$3qyBgu~_s^)m_uWen{~N0H_dCOX!ae^L9;1FC97Wtu z6IJgR{vLW<|NevgZJ2_?dez*x$*$e=^XHSaq|ne%aI5$|GQ!W#51%mwM<1TFrg6`1+_1&-2;pHvp za0lAbjwgtlnb6ft){NRMb?rt?oL~0T4Jn9!_#}pk-(Bxnh@YP?UR;^-S!iXWpwLjx)0pd-!M0<3+q=(bm@NfRs78C`>9h#@CJP*wM)&Z)3*MKEH-J`XQ7g) zc=x6AQ;+(KeD^Z#=~E&`8C;Z@yO(NwM)0 zWvfTofqa7@2TKEGkH$N!of{8QO*tnuMStZ!-t{YI?O3*Zil_f|CeM8@_YKGDATwvC z)`eTo_KEO*>REL~_Vel{=!^>Q z4;f3xr*boaazsLEQw^GR)Zl=sDD?ye|=ZrtTBFY!fGOy`fR!S{7dEQ zIiB;WsqWwtmDBIv6K@Ngjd#s&x}ER(5_#lpF+F|~a<=WDe{pe&mcVC+h%nX^5Pr21 z_ATkfek!&*Wh6(SZ#>m`qZKXfMRxZL9{YJ$$1*JusgJ*G7IibV>1jY@|D-FQC{!(H zOFIIF3PGX=0lnL%qI7iDTR!@Ocw`h5XI!Rv@whi&0(va^!1lgc|D?ycqcR2-E}4LF z@kwu2Oc!bQz-H1E#l4Np3rgaWrzSC?c3JUo?GFFwu)#z2ROfutKLp)(*h*C4#5m%l z_ZhFIsFK2*_~k?_SKhg@)?F=8j%S7L#EHzV1#@N9t(~I~FE`bX=uV4w-Vb=O#PMy@E57b0YBS-)abgvYv;=!g1SSK$YZJuN*>f;W|apQIGHy_@vD zUj4|0A+wu*;@8*NXyY$=XqLOgZBZ33JY9|DZxSt&;@)oGM#sTgI@tv#=a0tSo=?YvUN^{2=sB9nl7TM zSZ3uUU{)!{@~BYN!XKbr{umT=9THv{9^Vvz5;_jbU+5Ce)8#Qm(5}Umf$Z|>)8E?N zW#83~1Nij2*$O13)Lb12(k`9Iv}g z&pWQb;Q#`XKcJ8%7#;*`O@nEuv+<``uwOXk-Nej2!QsurzyFSna0AHah)V!vQLd&s zMuH&K#mNa%l?D7kOM(Lfmnz`wcC*(n8hYoX zmLPrv8RfyZxabOuK`A0EBm~VrU~q}xZcb01DrFUtnQ02tzU``eo_py4Kp|$NtjT#T zZyihna6dN=K+4(aZa1hU9cw$VzdLZ$e1i_Bvl@y;ZNR6%=jJ*NLS&PyS<&)NUCj&5 z2xb)tGy7iv5#9$gGw7ngk4>du5l97~<%6=9LN7`#3F6Xpo<46z$JhHfL|G0o}E8yO^H2;l=(>&$N;MdUmqXG3AbBR zgeIn@T7U+ij^#(*g}(tX=KEo!S!9XnN<#8)u?#j4q|26w-p5*28-k8w+)sz-d?{V8 z&bK3%=X8@LBz<0rU4R2dDa(2HF$-XoX< zvGI^4wgz=MYp3NuL2=gh=lsOH+^pAb!30IhmT3_}mpM_un04v{o*5Ks<_$(9=Hpf6 zJ-=a@wSJ_?d`3|S4o%ulP;=~0QY`~i4p6Y&UwEB)xE$> zwE=~_znMG-BN#7@%oKO z^FozWzoGtVG&(Zr1yeAjsqF>rb^e`Og#+#F?UR$#WN$$(Db+AN-a3@S*6jEYa6S2m z6Mn*nL!0ySgRlhv0%72?C9&nyPcB7F!7?fJZSmd^t<3g22;{n_={u%-H`QHrZ5><- zrt(n^0|~tVTeP%XuO_b0T@^12qK3gFBh;btn#)$vwv-muBpsXcCws6F9X$;C;t4fv zO4H8nt|SCqJ@dt$qG9Xv-6O)Ub2@atDDIzs9A!CKRcrO)s^JB3c&jfPb(?8ZF+&sWgloh0K(GS?A1kM*$L^5Hn%7@H;FuWI|p|OiaiIpSmDb$ZpCbUKooZaMpkqBG(In zrujl2YClqkXNqDe@&w|n!FGih3;p%79Jc#bJ_V`w9U*NtbV!U}zkbmbh10?$<}4g7 zlpUhiuejh*dqdw3Paew<(IY!>3KbYC6j8AT)*lL!soI+@w}t}xL$~XT7c_cTMARMa zz6Q3{01Vg>LQ~NA(wclzrRzNqUH3q~=CU(~DPncB^zsZSbttPWLPxikt-~Ot?*dy!zj|Ar%+6(_TL+CONbA>@S9OLW_I=(=~TE)3h2=NF)L!@lK zj*RLqbRomPOZUgx_5okxY-PEW^Z~2Zy$4FJD(~S<%*{q6MJ)PNbD#edBy`tek#un~ zuR{c868A^;YX)5q0vY~Z`E$!7xd-@d!fq$(G2Ui#@H`^6>tLQFUw0ghe-DJ{ zQm{I=#TW@P^GI5N@^BafLn)p=`I^u68$9cB80OGif(WYUxHw=Pn?lkXT{9eHvkaDF zU$^70H>gYbfyY!^;>GM{13cJoEew*~rX9M(`9G}TkH!hzAgE|t#Lu4|7azDoxC%FM zB&R8TACA^G{z8ZzRMugPg=wK9rrh0f!AOv+Uk5w=8htq#g6QX8-zLM^91S})Rq}CK zyNW?^0_@Us#!`KdxDFVp;b*AO#NoGI^rZ<2*9up{#R!@A$NH^SX>DxD)X}k`cE_&K zWHy4;+iQkHrp^&WpU_8ZMsKVGl3mzcg=w@^U1O-X`@^{6pe9rX|06lrL7Dh1anDRuxr{m!6kf;O0 zpcpVNH!w)%J5KBP7NiPX(PmMtoqih^Fu*xaqwP>Fl(i^l$p(b(zDbu@lP?0Ja2qOD zxHK|3cc?$EQbfgb9%f$2Hh|hB^o0QvLh3u#+1Xh{u{P1=Wzw+637Sedq5GZ{!3#W-SDqNIdT7`^{5q&?hP3^v6LNyubnu6;V7U|tQ z+-LI^QKak2SxT)iQK8VY&g-V@4r`(U?oYePW^bT%+MX>qX%4`Ymi<%ZYs^2cxYCwb zzf8)1zIj!!DR&*N5ZO)KD4o30*`9!_W(hCR-toc7y}U+zYP<7NAt)7Vp?mgN9)+d2 zv2s6^imFKzaZ%A%j{QV0i<=*WWfQtZdk~830&D2^bKNa187Sv{5rEo5CoRDRoQ_W7 zm|$QQj004iMCd}>TwT$u&p-L3}EaiFaFD_Qks%;tkJRpBfE zZq!3TLAvT@k$Ncq!Midd*_dSIj58Bxlbk$-JV5WEhV8+x`I!J!gXyEY+55P3o=-AH z6x^QVbR7>Qz8;_ZTCQta-f^g2hvlpJEr6*Hxx+Yr19CQ^C>d+*X65O|#aa5Bny$$b zAxFo+6pNPvw+=WF;rIB%W*+M}3pyyvGhI&3HknH(y7zYtW+Q@lj^A18Td?I$y%jhA z0UV@InXIQ5(cWT@7svqek&FfWIv*NG=0&;Bu$_6v4>>y@ZhU-?-%E2vLg<4PF`%2o zm`FCQy9D%_$e2)9x$gCL=w%S^a3hCaeF_M2wSCrn+oc@F4p5VU(RDa%9^6mDxr-|Z z&~r!|9}>dLB1G2FYSd6ky$z80MxvkN0Z*)6y%eFou(^&aZ9Pv@CuP6!IlQn!B$F z6HsL2i7Ab@NN>rip8g1dhx9KHOw4C^!}U}1>yyCi>2NHHI1>u*ggwgeLIHA10L`-O zEQa7ZdiD2~zW@Y;z7HG5UVuY(EJHyKK3AG#)Pw?|JwY@%RN`Dix!(LQmnJq@#Ip(cMi>9%o_XKP`;hfbYF`ARtXHB2+1oAoMHx9iU{6>N5ek3?U(Nz?{{Pu2hUW;DWqE_q+XxV# zxZRd|XvZM;LwzDbCrVHgtOd^?+Qaa9H>5gKk&_F$pE@eQY~cP7%JI+VHw^y#{Vfue zbn~>i(t_Z~bc2qQ+Xi*2piI%ue|sC}Xy$)-zXipAM;El<2&1>NEjRX`lS#4)B=#5? zcBJp$xn>3bf1?yUJoA)Jcav(O6X%we5Wm4QzV##xBBrGF?B+X2kXgmd#h;3Av|-HZ ziUo&elQ?Iic30ToAp;oh90xS@d0Obj2(S?MVG*PZ{$1ScCkF)(9}xufz;NgU9xFHqY{CE#su&KHlqS&FosMj3G#4Bt;79}nU+RtH^XLFaRh=m~h=KQte!yWb z2Zs*2-bW&i> z(^PdStOgjg>o6P=&lAGNxAHu>gJa1g(E&EXFRPw>9x>bjHiIFTk12Fx65(c6R)yD0 zpgWe5ssgJc825MfufYf|IFF#v0(k2ZHXFC~Xs$HC7G==T*@^v7*6n9jLQyvX?<~C! zAkr)Fj_pxg42&L+8;Hb+IXnw7pXHeuy3LvNc04^0w1>)E0K5%z4T(1&ezZK6X~>Kh zi=ojb;pHy)B>Q(B0-1q8`hzn@ULlB%1cXL7@3=^* z)5#j&LXEL5`0V17co>1Lj_MAe=>r*19K7yo8|{;4>7Kw z2vO0w9bpXEeIiZwV0uv*Y#RU2;jXomk4)RK&w>vx8NLM2l|mPx4_$>|LE>1O%PxCM zToA>>@VU>%1>m7TQ!-%Y19Ue@VA+YXH&fF>2RrxIY;Os>P zt<+y{@?634Ky0FP1Ejm3K>Glom7)~6yi{8K z9?#$L3sNL#uKdx~*|W4ShmXFV{B*dV5@7BCs|^4&)mw+K)K9-1&3c=d%9 zVU#+E57DVCoJJ13|KDIW@4u0onkG!DU~~nkMhf)&;&d8W%E{48bJEh%PL7V(u3rbg zN7ci5S1?Z6KH4^%KAId(pic zCW6sn&!|PJ>^V+&KA-HbjEs^XEVyb|94)-~hq7Ag3`v_asqu4IGPj$3jHLA*DpvsF zMoabzW_5j61*F&o?@nhd9zgfPJY4%&6DWaIq+(s!-APD?yMV2i1;&mJol8NC?^KoEBRKZvP$M>?2b_aux z>xXyIsfBiM22a&D#qLwFX&p=xK;!E5P2k8Aj}TK&yeO-QXm*u#PbN3+?aK z!Q5f!qyc3F0B8R~9GEP!va>*^=eA`3?w-Uk?&qb0g?MIEsZqwL0Y=T`?9GV;+4K2^4_2`{exWU>cl-x9~GA2~U!Dn4KJMQWFy! znVSp1t%G%nEx-H03~_De;6TKFE&Ag}ML4Qvs(fe}o)|z~5q^3=^^poK8A?`Ep~Y-C zg`b-8bW!I2q3Ro;bn;TNQsg{#Abw&Ked9K~I?;aix?Exs&tHJhoQeClq z&^lFX`@-X4quuQF4K~u5A$^?9q<$6q}=xIi|M9mgZ|1~9I^19Uk%Rv@t_}H<3 zcEp+BA^n#|Mx=)mX+uLK(WExe^Jl9utOz?&>krO#O%N~!4JNNng9#ZG%AFo#{F+jd zy*+d-EG;j}&GLVHTXTDz?t#VZrSo{!+WHL@j;%#I%cV6vi6)W})sCh$zb!>TF_R%g zpO<6^33o$9q&#=_d(RiB4~<;rw(-~jaY|iOXi}enE$pK^ToWWuI+xPweR?9?mTix6 z$=y&QR$%b#PoLn}+3!`Y*S^B!t~A=Q{n4#gD)N5!=hA80I(q|0!R4-Lc0Fc0+Zxbp zb(;;mJ*FByS@s@SUtgbM(v0nkkj>zVcwlx1guJ7vaWv{euUf(OclaD9cMGooI;f(u z2(S-lDup%k@`gTfJDK>N{*uP0}x%3!jdYvX5B)qcl zaJP#Iq;u8s>)q~362Q)VGZ z<)8O(d{h57QWT|9z_ih_t7-H6`p33w0${3sFVJm&`=LruA7$OqNt_s$Y^gC6|)C zJo8c$c4lTcgeYJN*eq?fVyxG--MXno?me-%UScWHlz{YKD_>i0J=dwbX`i>9w68zm zbs;1m_(nI^Ac!VXn3$G^-{7`Ut5ke4k!2;C>n$`4H|K}EX&@~E%umQp z!=(QOUB>(?wz%~sX<@TmMq6B%9zQC*SrCRDuw(80-oca~T4Sa`Y=5P@{5zN_g<7?I zFoY=|SCTVMK;Td0DDuu~^L^9`o&iyFe*RNUJ9uT)5Fmw6SZ;Ck1JnwuZGMJUuYEF( z$8%L92{Nq)I7*2$$=Vb&Rk_Qs@1a4&oKl*OLBOrqsvk4uE)eI z7~S@5=WR#hp1^i#fiXV4wSDJhWV!VRp%S8it8@;}+k+752j{b`Qp$Dj_+BE#1e)ae zSI2L3AtNPO+B&dd(uHFp7=}@qOd z7|!FmO{W-$Gi64W(m?@%a_Oy0y|ctY%5@)Gmv!2lyzhOkpAmF%7#paCL=fc8pUdQs zB>Gk|%hx?PoSr0Km(ZvZ=zT`4m(LUz3>wlIAI*D$>eKL7+m6u@5le0r>*_cY%a=&& zsZ3<4F%dJ`9ZZ1Yqo=nigQZlYlRpExeU?jW3}m=^KS_shK~=>FBtJW|>alSyY(O>5 zM*TB^P8HV(8`r#6a^e?xOK?0daCdv^sBl5fB@-?N4BUgU~<*~y-V z``PQ$w5mf*ZI<)dB|i!=T9izr?&R$3c}*^YrfNjnH@8G}gRf;Cix*}oy6 zv#l{Dlz7jX_v!NT@_>-iM z`}lZeb}N5)oC!G>%zg!%cKo89v5yP-i(^qEJ==3w=F{RziXY1sUPk8?@h>k@UWiL85$?m4{mNcvEu z7D7mf+{iBh4zr%qT^k)QuP$?X{>OIJ%oIM+s;TdyqG);IjpSHg4`|;{BVUA>3?KI! z9Y|Y1Rct%~WU`5x8$YcXkrG++QYycb|H;E_P-e zcdqz#%=SKRZ2sD%R`GmY{T;>sYS!IHd*b5c6uFXY&>yakohv>Vcy<&1WfMk2L&HKr zKwEoN5X^|t3=>n`MNUetyevV)g`1mG5Z{R2zuTo5=r#2zrCt${ZdMXrAyMWLYz zQpl;h_9o!=9~~V}Puckj8&DOS*;F_qth?H0?3`iAA&A>65)b@HGpr<);L)-hI*m`R z0fgV#**BAx)d`n@qG8F&Y@@E7l7b3 zE@80(9p?9DVr<;QO8Cw*K}ooQi8VCmiZxR{JlpZjqT|;-UEmk|DeXLHqWWFIkM2&_ zjp(S9Jo^I@$sWgav~dY_1!2{+J&j#CQ`GZBkhkY0MUR+F^%A=*I=u{72Ta^*Mp9WEAX=!I?joP!S zs%v+Hjkhptd~!p?VE%qNU1ol)>zJ7haRLwYM#k9x!vcH>g8RXcp?8bQir}KY{Q-eO zWWE`Q>!k2nZ8dzYE_#wR8`qgM&HiZQCY@QIGX4jOu1+7sZKgRCca#WKxu>@j0m%5rU)g-KXAC~ z^X-iTum!TfSvU?A2I~*9q7JG{C}|Y>Y-D20cHrvP#AIFG)<(BHFf_KWWMG;VhQY>E z4Qpo)%T$Hs(D|gPWiNnR38#&F8pu6N@_h%6b7x)>HHY}mja!+k90{~9;=Wr2O&=FE zH@OsIKE_Ip5j51z`ErqgyM?J~%?@mL+3Ir=*Fv>;{n=`hhPt}3u~Df;tIpvd+HO@y zIyIFMHdhl@7s?Q|mA9k{TUz%F9~hXrOMQBJX;H|FG)PP=P-}W(F5$~wJX(EWQrVwB z8^Cyb$AJs|-oILQ2MYM4)qb0z3uQr|fA8)dhGt#e$FR_$BP@S8FIJIO$ZWcM zS%V>fsNUpty}9t96gR&*+Ln_OYL4UzQB`=6k{KCk0p?0gMaRtg6Qr6#0G*=@4HH8n zV5+ara}=_9@=Z%fUml>S+1h#tK&r63#X!5z47!G}UMsImciODwP;jz7{0mT|@ zZ)s_zRUly`c0{UatRVf+*3d{vOtdEC{ffVC6PZuChKp;|dHMQu1N1kTW%)^|$wr2T zdpmpRXlTjb0{W5~Tq6J9ibAl2V`&K2C0ah-5oXQ{+%s!l22BYphNU$vSM%+4w)c?a z;@FCc$$@dPsuYQ4#>R%$CWGfo8mY%eX6$a`?J6g0r_PW^8wW_O^RLg0H?lz85i9rY zQk|{9%P0y#n#YmNfWf&I$>dFKM{DDI-GF%WM=1nroD)+$cQS-2N1dCSt%T!?laJYn zE9upJ!A6hyMMVE>xjDW*mdR2VfRy90qi^Xc3~6@!pp;9Nln#s?8ZdQ+ghqCX?aSh3 zq%%ge7VxtMOt2+!8SRh@}sk}Ruj>+@!jdHptg2th$g&}DJ$=N zZDQd7?)g+D?vO9C?ROuWr#L;W;+*2#-t~4#tcL-KJ>S^gPa2vNrW2dzOacER|G;ndH?eVdwlCinAxhz+HR^@X&;Wx(I+R!1?|l{)Mvi=7a?F$aQF# zbS@)gXZ*wEW>rH&7B;@ut<<$vHy~=Mwy8jDcJL7bVmh<+b@(^lYBMLtN~LL8TiaSC zN+CS!K+un^he|9gEDQI&@Sp2%6*VBx(Xq8Rs!lJx<^rl1FQdh{PW#XhD82c9vsVI) z!dz)T`QO{ll)2*PR6qRjP%YY==)gP2ImCLUMJ}k!*!+f3?{{6(T=kk<#p&;qtJO;9 zkr%x``S(iA*%sX8RW9CLu6m%xvPqG89}Dhkn)7d0XnLRspWd(gYZlx^9M?)qj(VW# zm{A7tMGy;PKoJI$P(abCG!Ep#uovssj7%F^>hDQQYb&LR5hwVW$DEUdjkaAgWhxok zG|QEz!^!N#u)qC_S}i?2N3|C37Hgs(wpjFz1fIw6$lxuWU5;vJe&gsJ+4SSdid8vI zBamtj(X|^K-Z0X6THF{ZW}k%dtExHn<7mgOK&2cGcmXNSdm^r7r<@}Kig)h! zGBF=(>yP}4m|MloillHFZ|kirgL!DAzi-cE+~aoFQ6GcqL^BZ!j!aF*FR(i6!a&1! zed$OASmwuam(jGUnVJd?1efs1D?;;zzNr?cziy^haw$~3w$e(QqinmL=(NvGji zxa$qSIOsCeGt*s$Uo#%Fc$U_%5wZskG9kjKY;YYbI25k`@`8G&gs2ExZ8icd$ux!=KRnkVSke5mUSn;2eE zvogJO`YyAEqRf`4NPg~7fCQG=QMIR7wI$0}7HmCGM!}uKc;HYe)hf2PN|ITLB}cCT z;__G-1xK8VL{ex~mWTy+Ez7>k5UU=@FMAh7w#xMcIdpnFZxyki32Pa__Moe7vT>r< zzo>%W%*QgKtFyK}Aj2Y~ebu&53Usa(j<{ZvNemOorGjA2iMI}9@G@rD~8)}(2 zt8^C5ODR1&Ka;*kX^p*FJ4%?-A=o*1s8@)i|I5ooYaJWuUvPv#* z4XJ6{)R4EU5^6b;T9P}_tG|43QV^0=$x6g)E4~C;4-}Owg};1f!Og*bCng)tfR;aty#6D4Q`KOlQ(p#Eaiakxrbp8zbEIXiue?l` zk_Xe)b&O@02Z(Pf`WInegMz1Li(w|kaUGt7Osps21V&HU?hd+*bN@_;Un?)Eu%@f) z?A7eE+3`_hv#^xn9__9btAS^87NWYhSWGG_?m-B%PXapGsi)RXq%a;fivU}Sp%I3^ zIEh`UOr}oNWiUe9$WV+@P(p(7dPWt=C}ezD8DU*iW|R`R%vzu`B*f5Ub!t@L%}AP- zIwTYJ9N)=&F_<_axUDw~e9SBN>G5_izLKh{Npp+>>@X(kxM=Q5n=a>f`)|Dmt%YcDNLk}2=z)-*nva=DXt>0uUyx4NeL zP#2${e}QaoUo8lhT8WH@clpY$%f&5Km0eIUSLi%G=qtWLtnyZaNdNzr-!o!M5?=-? zI*4;DE?F(}>pSe}^|y`tArRJpl+^n|)lD-yA;;m7*|J)y;7u0{_g$JlH8U02qnH}a zJW-aY=gpe^d&!DDDZ|!O{DoY*&f`{ztx7_{p0QYT0aMjgihH+}cI&uK^h?S1z+iE7 zd1&Y`6=2X_nV7D5aAusDuGnA@wSz$+f2)(U_~o#P3Ik}C9eG*3%W)96i zYb|iwD8vwpiOC6M3D&7842jwaR&hW;Mp8NoFS} zBunffPFKTEmGhsH8aIGrR_8+jXYaoBa8q-s5Wlouh5|ec;&Rx=Q6ja=P3xs{&PiQh z@lnLqH;ZQyuCdEhwG^XultCiKHa!f@OF0aNYhioZBPx;=nvn#_QJ9hDT{m0t3nT`Vc-qdn7N6~pX8Rg$WxB5X)tI<98R>AB1#u3tfn8{cQ`yYr$5iPXowVjf06YXM6aZx0_4oS zb#hlPH%dVU6#K+P?BP4VJ&ui<17Umn-zV~wB&}$&%#sYu6kaP z`g`!Rq~vgn4|(97NW{WI(t5abNm*HEjMTgp7xz+D#ZSztjhl^$ue`XH)^hu0>Aa;h z($iD+PG`IjQaLeMDd_YbV>2RKDr+^b2R_6Y>Llb?VxFF@iG|ae=fZXgtoO|OzR!1O z0>x;^;FBU>YKI%d;bdfF?CqJc&CTno%5hZ7Aj!xijh~ZQY=M<~Mo37=)=wySJUVJ> zb|xkqey5t06u_vaXi7KVW~zdJrKhWFrLS*kp#gL9!VaLd9GbGX9hBo3&C({5q(Pjn zkJb}(wm_Bw=P>Liv+UmJ?q2g|UH5O-e@1B$I(bR#W=kt7oQoN|%pMb}$+F7(cV4Jr zy_wZ`=N7gf9F3>IHDkAy+=ITmz1`iZ*$w;;3&3N6Ak_+UeJ{>;dEY2~<`PylbF&f@ ztc;Ie)Gjk17uVMI)bXEu-B{eH=Oj~J_LHp-gmG=(qmcet2|%Vvv;DEBo&6VP9e0{-^+ z!QxSkVI&#(-fixF=9Kqan({iH!|m|^9*YQmX``S0w!sVJw*7`6TOL5oMF1CfmuhL4 z6B8C*T^d5AKRYwslF1TmYUZ+!;$TP#N^Kp0sbkx7fr&CTH+OU8u!~ro)4r(_%G$1z z9wG(a4bLQr+HBCI<_qCJGjxxie}M$~-z&Cd>j&P|kH_{(`u7PQX!H?pH2O>Xgx6PW zaUQM*h;Ie^DJ-E{7XM&sJdzdG^v=vtpZ+~aZU7fhb^iIi)c<`F)&d6q|A8K$x3cpi zp#62uB3*1nPypE3^|mzgVKoYFjRvV85&#d5izjNgH zgb+X5|NDafy{uaX8wom*ZawfGVNGM;?;%A}3(9nen`bmstG}yOOG>qhX}4w&crP=&t_TyB0*h&G}A`;q?po7V4tz)vt#--@Wz zW!nyO3WU!;tH~`vq3Jx7zo&EAAo%IuI{;Xb+!!+lgBNE`TF4QNcq}cu2c5Ph> zr2if5zk{gK^?m@+^8W$sTZ9D>BL9F&|L+I=?{^k4;Pd`>@%}rzf1YH4Ws`YY(9`}4 z0R98|Y54tfC4dKbPE=G>M8w3^RaKzk;@#hnfT@d>Rhf$f0QLY~B!EEzU!T7%&Uq0C zB>C-IM__VtGGM;sjPOlLikgzLGqA9*Ffg#s(J*fMFBog7LqP&1>+-p7+%!%7KjhI%P8Pq3o7| zhN8vg=wuW-oA>D!kLwAh>`|-L=1{sL#J9!6Kr!~d{VmR&<4)t*x!y{Z1_0O`q8sotl0?|d(9^mY1Cw_E%45&z;_|E~;+S8L0;L0m~ z5Cu;!1{7P6kdQdG^FJ;mq@{1}?=unw|g;b_q-3M?-0VkE6;5mc$vDR##`B;?SBQzteOV3}hxrcgJ z<4Myw-H0B{ekiGl^q*;SIUKUM?AV;0*<9KT4-fLz^tT&fWk44g7nI>yc`ZBLVofBP z^RbsfTIw{O4X=Am%uOyfS~1^Gd_XIFpAbw{EjwK+Oa$4Nr8QUVm7}#n-DO_y@kBP< z<1EykZ`(Owo{>B< z()RaJ6KQdNuZw}PTTKF&sAL|W*dBQFx9S~s4|nlWz4^)6F4x-nEQ8R>vKP1*D*zj4vJ7 zETVNavHjI0v#`qIGoqjBYh{|by+HbIuEliz6zaM&h^kmkMi6;=ZmzDTZtIMDOY-s6 znwCMd_}D%)ih-3~@wOZ3@4){UX_*f&qx;%VH8hN?`Gfq)FG98|y z6Uj1|jkVX*N(brRLBBkZS0zn$>RU=N7IgXWm%>X)SrkHU4TxX)#J3@!pt^PW0Y$9W zYsxFWyP268;H=Z&-tk%w3^-s3E3ykDyxNFpb^>lnTRkCL`!)Kl?5$9%ufnO0K_w+6 zKqLhm4o7sec@h(Nx7~eURaMp2mOiIXMTne5FQ{d{*8*Ms*57S;m4!LD9Poz(jo~w7 z6-o7U@_~r3TJvp6!STQ`e`AP;%=uD&mK2WMfWqMF3=0djT7#yhqw#&&I62cdv=||e zk534MT&19-I+|%MK24v-2BuAaNJtYS!xYVw?La44eOa@HM&fR(E!?PsnbxqAAA)+u zAnA8z7ADEy5I>>4*|o&^p8{@XST|pT!8z3zT^Bhywq`pMU*|wAB{ZCT3hNUSi=(1v zwil_ooX$wtj~G zxOJ@!H;x{|fH_6m&C#Tw!Q;+1r06G5mIxhHRaD6OdNv!10yybYs)jwe9HW&x(7 z0K$gXqsB8`I!WX0%`X9EZRLRNj8jiQZNs4uz4_+?8X6mKO$V6q#QMbCd=CSz?XRr@ z{ecwRMNTf4CpSj!iyra*KGEJJO$~?BRWJllSy^OM=%#%}dNP{RYhZkYOi)>R(x_Bu znCg4uO2@>9M;0c=gy~`>^{SFQ|Iw*I3@G%lu<*Kyx~i(mJ-9F>B`w2)(X^71vR1c? z&NzZ6V?%w579XUerP^7e^Jp;h3a9D5?0dvczbzxEMF>> zblthc(lW#Q6>ab`j>Y8Xwn!pSxS6am4Y|2^KJ_Qd{Kzlfr+0O6ZF2ecsXaP0<9Bk# z=F#TfYNcoO6(l!#b`17GZs=r9T;OyzGMZ)>RTw4Fn%536bA^NigNn+;RPxDw??8zI z_({#gIbtdtirim-E%Qog4((~^X^X! z<%Q+!-rlmJ9@zsfq)?C_S;mI0KF=@(q8iuF7=|1iP*~1>MP{C!-d!RjKmDXPFDw}r zrp>dGJv=uzqo$@FTEAIPV`ZK7?S>;WgCiZ7KuI+Mg^hJ10 zja92V5r4!I2`Ayt@q*LWKzJdr>B-qrP)UzH8a6o?nv$++KLS8*08zmZf5G}Y;~_uKO6ED%t;S`>;zj80B&G@qw)@wvp8s<#5({SeHV+zH@) zAMKGnS~uq8=mV+8fO59Hw`2jBFJD^3j0WZwU>nH`BI$+!X zdX_E16v}0a9VIau0D?7O($-d1zMYby7hLPM-SGxU!a{O?^!eiXP&rrHJl%n00?jOV zrt`*?9;;{}K*gV#ni{rGlvzP?^#w&x+~aDHe38=zrTYrtVY32$2UI%}FVtm4ML*5W zp8*9o1}ZA_DFdL@*%MKrQ7|+#6cG3j`b)DD8nCtKgVXhTG*ZK!#X>_1uqW^ch4R># z1j4rh6`_^E%<(yF2)Mc3^HB4)G63I-9+Y*LZ8+#wfWQn;Vuz>Y_|p>;gT%?n$cXse z+}r@=zP(ez%XL&!o7XE>)#V8D_{mBmGY`)SpxlT6{?{HL60r%+eOmYuTY+pBoRd9! z3dA-1b%OLbYuvvW_tw(FK}B7-0IWtC#(<3NvhpPu-#Vz_4$IN#$cu+0D9D`agv9P*87JI_7Ag&W2 zJGG?+rxwN={BsvO4~~CPwi$8v!G;{~t&(EFXQf*XWlklFDGGqdk^wk92T<+hS#k85 z$Io%&Gyq15!U$cY6;w^wP2T}I8Bh;H+M-^vwx1s#vu8IA|Ih>6{X}eu2fRGge1w42 ziLz*34n1Hgq9$%7{e&ls-N+RdqOuMhmX}#pn?jo(a>xP7Hn^ zHx%zdzz6h)91HeWv#C6ZmSZT#(}^AYJmJMI#f!+PY-Gl*&hRvcY{7i=y*z%P%}Q&o z3XxMXn|0TgX1mMbFts~Sd;C7_3$}(n3NJ`C?X~iVh_IILyF9 z3InXIFGxam8R2m(98aS(aXr6;xN#U&>K-m@!xGtPG-uuAy)z?ExbdB8mky=PkZ6ctE#w>(ahF1hDHIGJhju=q_z>9PJz9Z30?B?N%F@qjYB| z-TXy?bD(`T0G3wW{Sj3)l}q)GCKLJ8^ev1S5*MYqT=sZVOW5b__7?BiD<^4HQ&p?+ z%WKd2j5XkGs5-c$&Vk@|R|A#N=JWjTkKB8EV#liu`f!0y^xn@34?-v-u2MuIG|0=Cm)gQD7A=8omL#__lzoY@>Cfiho}Gv(cnM}fKic6(iw-!5z? z4A_Z!PSz_1c_+L;B5>z5nCN-|t>$L^(Q)=5?kzvd~orcp!~U={j1Gxhx* zfnPZ3@hwv~xx>!~NP9es?l>O!-5B4yxJ~}9enmaBP_2vq)0T>jMpru;*xHFZ9%y@o zZGQrfCslxFv&MhF*#U)04kS!`QYZ_VE{sg&g3;*Rd!G|Z@&P&jm~V?*Z5n{_2hoHy z37aFKLk*psufMTe$79$~n+iSGok~i4dpdZge|y@*Fyy1=HF%F~U4OlXZIk+O^7hp|RDQ5HCo_e@?TA~a$9 z>-{s+ANez#(}edIlkqoC<5AB_O4shu`Ip=BqDIa zE?HRE*4mtipSr@X_l2TSth=|0PR0)88I~4`Vy(1bmzs_CbISPwbH*>L*^7g5jEYRX zGTUE2(u6PLEuBW0yAsM5$pDMfjdo9jwqUZJ9-+%q-iq~G>iI1Cw}3IMN()6w2~DPNAXLd%iTZY}WdlM2BGFdt7|n2G#%%2Ld)A5a%k@x=ruB zi*mGZDoE(nHxZo(Jq|CXWs@(I5UT=tEda@%f)6i7)jW%~Gdaw=R{>lr-xNxj)R!L%g#3HMEs)vyWT6spDHRm$u!nXO91}g%Za*PFodpvBO;;#0j1ziVfgIN8&(5)8X8Mkf_~BLGpH!gn{q+(shDt$ zqP>Lm6B=aFNgJD+ABj6KUnj-!Z6kc{AhiW41_k0p(wSu7&%9c^A2x#;`2B@lIuZX@1H3+eJ45BDNYb>ZjoE4BV)!JD9$_JuovExG zaeMMVWBY6msQ-#1ZM)AC<8_MF&KY`vn0fe*#()y|kN&qA2Krc&AIi}F@>&i$Twhzey9w>MLLxE_6i7@8JcpobD8x?L;||#!#Cr!!-BfTv`~J?362qFbZUlv zCm|sbfIzKem}jUjoCuWh$jMPY7iQGf(qa%0gzTKCSopXsFJQ}egvHh=Y%BjDA`4>r>rVQ$bD^c6;wx6j3QpmH z)e8d68Q$G#enCN+d&}V1NSFNJu!UWR@~HUQgZ1+1ay~H%?MAI``w71j4%Gl?J#o~1 zf7Hs8#L@G#YnS*Lo+*f1grMPebIs`E_lu}*xKgSi|=aDguHxu!z8O>JtsbfMRJ81Cna6XlqA9{Ol zKKFI0u7xL#c^>T?@wiPrczZvhbeY_@Nfz9y7)cm@z(?%b$|GIRct;RwC-3My376uNYyk9~giYr|x)kU$fjt-85KB*SL?_tn9-k7t{&M-Mm z{wC}p!cLaHX3aWhwm05p`7^v932?gm6a&S?wp{)0T6SW~f%+G|*YiEQd7&73D*kW%LYXps zM)IX+@b-8{Il_>Mdwn1#BdQ`JTkBD}Ql6`~7xxfbfaRZ#RU~cS{e4#pugy(}(B#%M zA)}kdFSjo6ezgc2F$WJT3fJtly_lFG{&Dh$m)grNfP}gD$S?SDOkPSxTAH(o`3DLY z8~7C7MZ!Zl50l-<%E^fC!(DK5n4jS^It9-NyT5?Z>v-K?N8RM}+fdTcNJ_2g(Wk#C zw!noIR?&2l;d|Y4E`0&t>%Cs*b=-NVrsp5mz%>(Bo~>q(RD%&l%;PvnlrKU!S%C`B z=^+DfRJm}OU(xUFQ5cxt+7S*84uuG$CBBIn>9I5Of+=6mo=at|8_YNR(S!Vo{D{<* z<<$i(gEZ z3aX13hTk~Up%)qw)t68>(B?ZWoYgB3;F#h{Ya61|T|(S|{YD8a9s{0JW5uNM3MA3< z`}w6tdP~gmNqe<6k@?j8LvVnN(h+UgC^nCji-aoX&9T6x&X?hbcTBHSz#!@Gx{xF@ zRG_|_TdQ&l(+@%oJOuF|G5X2>6X9n~b#<7=m{(08CRwDxh_h%PiwS$BfrgxcQWD%J z`Y~921;!~EGS?K!cDQ>C0G9${F~k;{9I0D2qgiao&TIgu?lPDPj%>gNk!cxlWB0!7 z#b`)CgNyowfc5E1ULpWKnUcJ}-s4lDQlZ)_XQ6LY1g>@KD{goO!tO<|ul>RTS3vos zXJ^OcalnD;Iy^jVfDmvH?0ex8511hd-wIy&2n<+t1@(mZ1wsW#98C1rzStR=GEa0Q zSGJt)tT|K>l z+x_!IgU1-*6g(^jQR`jhc{Xex+SsF@x6u z7LnN!1_pq~GUBqDl)0O|C{mKkNGinlCeRvqBm5Ckp2!?Z_Rmcr!w>05kUpQssD-zF z$orawnY(g#GmR)vTj?bs$;8bMq`rx8rmke_+rhnJB(?x$bm7-i8ECx-t9X~Mf?*{a z?xL9{-dfdpdSoGpM&soD?Vdk@^xL`VI1QQs6V*UpU!Zq1NKTE-Eq6%X83^AKA3gyb zpD&QQ-nt};A4==jx*#=J-$jC}zT%5T;lcIBc@t%ffy3Y>{mIMoC)PmQ(@|~upr4h=rG@4?S zf@?p_U`HFr~s(&{ie{G!)Rly1$xPpEh%%gd)R)4Wbi&B5xoU;l#!y5QoTQ_ zHzlNOgy#0Noayy}+`SZ)?W=n}J|;sdA8JR4R1W*~0!;2wvlx6jgK>-p(ddYr+r zfs@5{gu)b{c=d?(dEAfFf10I@&&)vDg==wupz|t#x10LOTxn@Zj!AcSz7UI(`S3Wt zUO5K+6R~!EOqB?(D*0zNJp5O-q}@D3`JYAqVF68`(9CW{HmKaH5V}Vm3XR{TadRf} z-r1y_om0Ol+OjJrPv&*>b-_TzpvFLBd5!Nw66DE-cJl{;!P2rh;wdk6Di*9gx5Mv# zCqtm5*rQR?eOWpx=J6k}h*yFN^-)r^V6}3MMtPQKte1Vy=N)6x3(uFaA64B+TZtC4u(iXEP=1$ICjnmZfxxh zNi0=J6Zl6DN~FktLV+d}h>UYGi75#s*=&61@|zpr`b5oF(uq_W!8K2IVdvZv_P@Pg zJR`uBbHnHtmEUP}G$M!DCZUm!n|!s44`LO1OSBEv*7_05|0WCrc)1wAr!y7i*-&vJ+2H?yczN$Qp&F2~xCd4S10>C~WB&Cse>UI6 z>Ufl0T^n8a(0q_T#8?X&gkw4Ox0eHm7@#m_5`sj*3wp;`1SBW8)3}eK)zQkbAlR+e zni|x^>fVKCb}u>EvJrKp@tKtA=LKI{!F9+Ew} zP^ZP1C*r6OUH?o;ca=ts0IQkbt~=_>zv6tSV7lKf9CR`l+B~^I5`W;gbUhP7VPn{5 zh`HfeaV4`S9wd>x)247hW>X^-Tl03#bKF=6Pm{ksf>}K`PEBo8>5&Uh37Grjb!JBC z1(iED#)#l-3ddNQHn~ug^}196W>+h*=WlKd|zg{vxGsJ7#zR4-4@q6Pr89(uYbu9q6U@z^6M6rNcSpk1c^EBCgVG_;z z!;HXeg|>sV@~iBu8ZG(9phvLUf>OW6!p1?J^v z>W7&8G;hdb&Tkv})BD-8aM;)2O(Ou3G@SVMah%EiT!+II9ZPSWus)I7$Tzh5$EHBW z^^_~LJ3@;=nIo&CV_gctFu5V5&cKLtqDRBEkF#{i7Ax3*^+f&mSVP@4__Hf_{jRT# zt^}A92qds@Y%6szi{BX8SoKm8fbFND&sSH?Hz#9x5G(CDEiK~hYa@g3t#IdTcTBZ- zpF(WjgcF9SDWuzW&7rn1PdrRK^ioJjiZ{RgS{K}e9isRyhx5gBy%yzs%S3d~h<1QK zqit=ZeOk;l8$;({`bzzkWnanmPgzBIL3R_1uYCePa!0 zJIOC0bR=YW-Y9;eoD^6I#YyL+yTRc!y97PP7`f4mPIFEeMq9*Lg4(Tb9u72IG4Z9h znwc%@rfXRy!jdPl4yL=TjmI`9381`4I>CW-( zlI4x7?ebW+b+EFBohF-WNle+yVgcw;54OJ{)N53OgAbl5Pd9quPe5RLdh)9G0l_6O zr7^y$Jv@Kl@x`YKQON^XD|Bw2kpJz{4sGx!1C3l)KJ@*co%%8$;>-Jm5ufan&jsM1 zVbiwFpA5M6v|bHSKgf}>vDFC;0bW8E{9n=rOzwW7lFYgxcR6& zSWbR;)6LG-1!;@~u%m#M2ADDd%Qr5-#F*OAv_m@)O59)5bXjK>Lek(9pr_#|!EX6j{Gzhj(pR;E_vFh6 zJRM7BhI0IP&n}XVq3bc(-7Va5N!Dk8h!!?+K<^Zt@->yp*CB;{r3~DYJl3+EPsJG6nhfbQbd@Xh4fi7#fAh)@GKi}Alb+peE@(2(Xs4?tZG;^ zf*a>LWUAvCt*{ho`qtnZvhbh(hpM*@t7`kBy$>p#(jnd5-7VeS(k0#9B_JW)Qk#}; zq@+`&y9A`Wd8fzoyZ61%;~zfk#b$38YGBA6kU zz|ZvFm1^~h$swkaiTGfyB??Y^jCXC|wqNX{l6(}w%3jI@&*X%Qa`heIxb z1(ej?`^k}nSn3?lo|FzFR?75QPiDqqM&*j+fG@(p{k5`-j^yCVCNz=xCmP!(hj#knpqo@JvdCX{>%}!Op zL{Czw`##oIG|K68vOU+!3A;Ky>K7w}Hrdan^V1YLto=K&EnPV^2AEUT!bh4Mq@U6} zMy&klO%xIgLpTmWYq3|~juA?dwyo>kCoLu^ZsJitW`07Bii18G`gc7B_jDZDg17y> zC1iSabucO}E01uaXYixrdKhk7p0nHU?{c1%Wn2u2(fra8(l*w%)i>Kp;^gFmSF6nT zpKu=X>JW;Icqe1PJiGPsXoy&p^AjeH&y8pF;wz2#@1d1qs(wt02TeGz?zakQTt6Ka z6}kFukLTD;GR#(>!D1{`>}ZxwITi8jENq@i7ct{q@?S4skf<6794!q^;Ez{?@4$#O zS{F|EizR>uy<*%A%i`dOPO5rAgT!qzDCzn2Tn}KLvIr`ps$6&|V`5^q6~BF7Uk<1D z0@E(gQDY3dKHn$ID;7phN(u!XBz*~Bvt?%x%q@)m(4UOr?tiRGn~zC+yE4i3J$2Uy90BpeBJyiN&GHH?`bNb z4q6S8l}$``2>CY9Ys1Mp0R|bs>+%HGoYUTFmx%q0g*Igk`$Wn?o^@{(1p0+{Lf&Tv z4{q{Uqout=;Wbyt(1CFq8?dJB(5za~iMq)M%XY#rlzB&8Hx;#NPEAiJqfUCQjIBwD zB#((sK;Uz<#4CWQ%G6bAQ$>yv^VmU5Nn>xTU3i;Cf-$*tN6xa*ur=d>z;7$+S9q zf)_ods)&el8yXfZY&*k{42&rlfG)Rw$Z)+LV_>+9BlKEPmH>=rA)J1$>(&!k!X&7w2ZvrhF@8(ljRLU;NF3irS5CmHHM;xq|T?;@#BD^lT%B zhH(^%y4q$|T!UEWQ#fxwmkuaqQ;g=HOvxL6+*Z()r<#995lS;{#MfP{s;-0XOhP^B zpC;5zFNJ(+$tEZJi+l`Cu^EAI_pw@7(B+~pMdNqA598fFMiuo?T7D5c;?<;`Sz1&{n_`?DoCboC6mtTHdARc2x}!m zX&g$&1wZ%}QJ+5|R`v|fgdcwZ?`6E_U}&#)16zbZqy&v_>0@W!Nv|AZ$zJC!_!gA;(0|OwMJw%65jnTvd4HRXs36|ps6b|a&d=jTj|0OQX`|rf549e- zlhup@7v*ukA@_7WJR(Xfq%mC~*>T#v{e1*shC&`1vvzVlCPhS)?_a-)+%#|&mJXMw z0*WOf>KQ1nm@BhWpw_xmYRfVr*dV6<^fK4~ZV%w**}eopsX(Q4hvfKO=@dtILnGlZ za;+8l9X8B`$C;Rc>$W8$V?sooaa%J+p2^5zcT{#NJ z4%q&Qo{8qHPvTfVFo;z}5d~<3sp>+Lp=xBvSk>Eo;TW7??z|zJCGjmt6B}XwBxyzC zAE&8Ub}G(nhN5rwPfnZ#BxECAEkj&GAtQ+kX+^e7mRjq$LXp0R-hfq2ZpR&i;h73a z0b9|$!j(cLY)M>4;j}wjEDuFYJ_EGY*&t|vE??S?5FYtY>-V*99p33O7jl|tn(&pD z$vaurDRXV-(_rR|Fi#GWzc-OoDBt^Yop_`z-Y_eSz2a~Af=?v|xkbevv;c_62b$;y z(|Yv=B9l6#o<1#J%0y-=Z>Exygt7~a6a}c|23sv{&l|^~CEI~`V#|^tzng+UXXolH zlkIR6pr7?(PO9n25mI>C71g;Gzs32C?!3JeR#s-CWTfM$8p<&onp8E!4TiuiFtc4% zorb`5)a6}-R{wlf>)g5d%i@;^wkp;Sb9+lQTC0~?90kzXu5)P#UsLqn>l zwAM_@rnR1@R_SX;ribGTzhDKM;aUDVA|C`-3G8pLVB+7beDO*B5xy*C&5|$*H^^J% zW>t}*V}@I%%0!l;BQ=K~mSXay##z+4bsCyB1~DchlRMU8M}JN?!;1Cviaeq?ym)B{ z{LT`$uV0adG@{g+5EDO2KcYA%<~Z{kYO?ldFTI$YrDTl^9(}X0SzS~q=sqm*l+!~* z!Da|QmHK2HJZ4$}XB**56ndy^u#kV^Kpx;M9%1uGb0_w#K`W@ku)}#c&f@f8 zDB|jlzpceJ^&4mO9EA-2KXlYEo3zcn3td|BUYfOYuUc2YEOWIT?(DM@)yK}fL4{LUuv|Vj^Y?Fzu&$cEXnn61;BV*QdOvxASQ|T;o>RJprNG3 zKADe}Hp+v8J%of{kh-ga@B&61YB4+s=XsKiV0a$vcpUlUo<3p8m(#c!ZW0d`^Hgv5 z^nhy(4B>7Vj-hdzv)VoscE}6KltW-`xdK}{aLcz)jb%YaYh822rsLBW57X4r>XjU) z7Pb9@O$5z3=`3ohs=?TlS-4jb#QxgAyIiF@D%^fGdLK`~q}RwGES$>?xAi71#=dtu zmOxiqJCeh{-*HIF`W;rJfVwr+)P0)8H_lhCwfA|fYdne}CVxs3;GG%Cf_8yG31fg5hl#XUgA{gHUKZTFBp0ICS;Z>y6sLvh2WOO%Ro0% zFTl^Hg<(Onv`|Oazxh`qABO(qTy~#b_67e+lxxltosh5tXiD`a8sBl~x$24uB#tz9 z89b{Ti^Pw{ceOQgYwQrLQpD?@f!_4Bv7%;|M65;Y=%w6^1c$XNrSBkMOKq$HJD3v& zZczO7cHxs|a+8iJ`IJO>JYm!^`@P4(Sn@bP6$x+06Y{j%ZZ^*1JjFks2-esV3m5iD z%Iq9mo}@^`yX{Ux5aRrw#Tg*=g=P~whZV>o`V7qUiw!39Odn!DtN^c1$vX=%7OTFR zo`>L@UF}AowM5nYCY@-%$Vv}0v)Q=7+>0;`At0<2dGps>aBN~dTaCGcDq4#vlW{GwoezX5AzF}4mX0@}9xpQ3O{#RR#l#0@izShzfg^9iR zSKpC^o*2j(33-1Z(`ONKGqLS_)0l^g52hHPJei8MjL-J99RE#|Q8w!7k?c#q^5s27 zbb8ju`qR~)Eb5zxXjCP`jHxvIIepGW?dazAm*bfp$yHjgW8rRZT)+O)6~b_$F5qBz zNI8OWpx@O}TEimD?QlNau)&Mdi2XVS{U-r z^t6&#o0`gLlUGQffs4TZ^{0$vG9116Qs_C?PG4~ZQ{h%@)ENTRlclKJZqz3+BR)l5v^nR_jZ< zySwza-YM!x%V};gV4~S|%rzt%&}# zT9u0|hUk#^i@=L)_F?nVb2%$OP*`EvFculO^Bmx+2 z@bHYWVs(4#Cifkwn7ZG8&!m?RCEMJYv;qxp4AYr`z&^E)*h9n+?@|N4qOWPo^9(@q zCo5#sJmXQp`M4S%?rS<>D5J)-kTm2!g{_nH-s-4+43z^#fz!@g716H`gfKBYzFJ$| zKD^e*CM=;PEKi6Qg+F6)jw>*s`D(2^U;?F1IUdn62|YQhOxABP;~J7@U}6fY^hRGt z*;9~ed7|MH9KBS_gEt{yl-R<+zKz%eRXPK~jZVCwO1Q|xsWgMJ1l*4Je2AeUxKN+U z_oH@gX`sC+->+2aaetV&k;i00in5(jyyZx@jLf-S5LnJ(q$nGYK&lnf0OCl8H(I_Z2Vnp&mkYhe@bpBQya0&iw+ zmLuSM0eqJ0++yga8`2+$dv|bMPaWI5$Pd0XVg>y?v}T^0~T!YUZD zCp-Z2L-&f+#MC68hqZ)A0uy#a|IP`L^i|Y-MK9~yzP;MxP^W~c)Ct4DxqpJXr}}xx zI2dkhlyzj_FWCJb**~ywzo@<@6v(qZCL-%!{oJRLRiv1O^waw zr1HAH1)u9#^;r?Mi^J*H2Jr76*UE!H7A`-n)w6kK_UGYpZC#_3bFdaHaZv`c*-LTq zUR#`MkzaDra%n8~!+U`k4V(=(ov-vn;aHnEBF>;B5r$=d@ucM?- zrji887<6=0yf$sn*h1vv{GA1)gd-cEZ=pN0`9z9D%tiY)sHwvCNZ7v?HeR{Axm`wF z-B1otgTJ7?K?f8d8*3T8Gsys2LR<@;wYB|Eh!0NDC3FKVy&wj(fY&Y!+ho9;F^7e( zNli^Xnmbg^pt6jS$m?i?7!rSR-tS)rbi|F{)FB<%nHBW0RaC~|axW|#0Z2y**9@@5 z)I3LpKqZ)PPwh`yP5I%E5&Xa`EO=b&+34Bu7l)17YAUs-(bBBzj@}zF_TLZZV*f9CjbexJ%Jw%l zoz2$@ucUL%C=e;GDzdWXq!p5S^)LX(Oz|cr`h2fDCN=;D(;VGX!x8>fd~pR_zc6%; ztY<;y*(&r=ZcYvg)Q4-Gue0#$W}wWX=R0t|BMA$=xxO)aO-)~A|7NE6{r|Lp@3p=K zKPIiWioJpcA}TAW1^`q)FZ)vGs zS4P@}d}vA=vOj5xwm$`jb_FUeLt*MrB}isOJk?<_E1cOt#00e$m%!B7E55EB!A9!w zVid0KWZbH`Mfe`{fC@kBpUNxlw}+#`9Xa=>1ZLJ)3vZv%PTw38mJyY4BmsL758h9x zdpHA68d`>l9VkuTR|oKVqN86Mzz{>Q-mv-pR=r-HL#%~`ME?v7NI|K6sZp1IG&kmd zI{<~26tz+h3)YL`ojWQ_pO0|PsN03#8O3nLA(=e+?||-j^dVoPxjD|$A(%U;{|iL7 zuRMyT@)27v&9-8jM)&dwQ|&dF(C%r+%8R+FaUP72&W>i-evCjCqVoV=8_AyH7z(Q21o+zX z+9Svc%CZROXW2^tM6`L2wA6W}h(h-H%g5npgttZ~{O9Apv0n=ATsx#NJnUm6aJ|Gj zNrFZkP)z69OD{Q6;lhV*C88F@qUBA~3c%DN%Zqa}RTQK!L# z7Esr^LqrWXQ;v-eLLd|JHY+R^-Dlxwg?+Z=ME)?bH|@yXze1hU)Hc+j`fm&?!=DW% zboT_#H~akL)MiGy(i2ZDDRE?ExYFMKX8U)a3a8-^X*vx*J7OZ>R3>NyQKhD*K*bvE zU2nDJ_v-|2W0-E99_?-v)>moFzshFi=EpG&KH-sY+Rb}-?<|&BCzh4_R#Q(uTAC2? za^`t_QXgbLPgaJFT>i>)k;Qecw_NSaw;%ksTnUCoNfn2mxs~^4cM5IrGJ~siluVNH zg8~EpS!Yuh%k%jnBDns_>4w+>%%$u;u@PZYzq$=#Oq`9rt^YXPcfP#5oPP^cr=i<~ z*rLH~eQj+|u)+fw9lg_h*~flo_cdL0G9stCIvI&=qFEZpi+Ud6EY}o)B~gk3_3;lM zS-_H_qD&{c{uPgR@{U^JDIzT+W0;=423^)3!$oCOsW&h)h&^cP(w^+m2>OF8X})+w zL3T|kXX)$-)nNZPo&CzL@`>=-dZvPJR@ol|!@no=HI-%(nJ=o1EHd4dj-a3**P{i7 zwWdoL8Ks9rJGYMPy88D%&9eF{Jv)=CFDaj`e%`CeOR9fT`EwT6<}DVJa4U1qlAyxd z?XKZlXvIZE1&OSHCDqd_t0u5wOyjUt{rwHC-ZSo^5n+qi3Ht3Br$|_&`8yjjB;xh% zlEBIMC7KicHlKz~-H=%NnGabUitS@H^CVbWJ+}RJ90(k!xr&4t{{AjSqTx%!VFClF zM0g<2?JCNpN78-;?R9HfqKKYDevA2Dk&=yqxOS{7IteDnnX$iT7@fH#BvG^h9vgey z{zl|0H>*aaqJV=(l1mY`nuNx^_0bS6TE@=>ufCDpT{5nbDzY+gi#VCLH?Im#l1pJuk<6SgKrIU^JFld|w-!K0eEdwkH zSd}T}2(i`SG>$2l`O{kv!cF@8lAT2;C zD3w@@R!^OQjBEp}h{eN^tT|RdmXm{p*sO)V-6?E!Q$S|j-@dHguY%Wt4U;f0(x71 zyRWZm!#=_`Sb>Ww-L)-nLf>A`5;U^@49n0B-fqd`uU) z(SPDIC0Z>@m{gFrD{#3ZBgXz90 z)D40O&C*bBLkPI+XqcI`w6r3u1dYE2Qs?^CPL=b?tS>NKcflM7xcHG&UOY8rMYOXE zwHm;S4}kI=)HgiFR#uS{FThX@G+~|#TjKFLQ%IvJkl$JKT{RG#W)G|ktzSwyaC`gf zsv`95QGq5`Dko=`+r!_)m&nL@h}c`(dfGajPfv^XrY+3i?TgX=fs{UTz3;y-n6>;r zIS#vq%(nD*7bbckC$as{j`eID|B@i$sZiZ1EhLH7@I&nbFOq+3^pF_{3gVzPf&QY8 zn7P_odOoWb7=ApEsw%|~u@TBP)~4pZ#5j^v06S0~TT4K&r0+9|0_z;2OJ4oaU5w6*7wlYlQQ zE(r*Ce`yFroPQ@A|Bg%l{eJNJj@bVN$SIP=d-?D<{rf~{eIcd*5dEL_`5zp^@w?Qk zo}!Vabw7ZBX~BN&yr-;b!@vcIuSg%p#uyF-`7VA=&zM6l-5Lh28G6H&X1f&+Ta7no zD(~3uwKTLe_6ahOaL)y17al;);qP@V*3A@BeLNjirVauCgMFG;u)wMo8K0ZABuZ5+L z95EBR`%mEg|9!8-6lg0_a?%qk{Y`}|B_!09?e&hoqWi~l3}WRgw-jSKov`;wxNbtDY8 z6=8KGf3HJml`j;M6#x9cZxaSSwCPK|Tl)2ow;hEPbk)OE*nU~`+)2zbj68adT5@j7 zWv=Ah$pMG)ZlP9-W)UEY*!8Zi3bV4R?D2x>%LXWk`v#N&>*>Oaiwl6Oz{SJ+?{9?) z>koy)lVSDm5Ij;E`ERJ=uP@P4fFwv6o(tu0PD;fv{Gu9vv9h{)@#J@=23C=UX50kR zO_;6yolG~-Pt#O`{#30g!}7cBUw|^NnjuLAd3kI`-M+u5AAvVF#FT&WKz~naxD6BO zY+DS^7XFR?2J|5(?zwPCSjWXNW24X2TE>UMw|4SrkNI&`P#&wi{trAWM!?7Bzt;^e z0Mk7^!Tz-F9OD^9FCx{pz5ssFL18>^?_HK=61mcZESStHi1yF8*1qi3!kO+}vF>yuCyXpGuzf z%-&m{9vTU|*vEl*NN(57_}9tF$vHSOD>3=3xVX5G7&RFcCnpsr4fAaf*?ZkI(IKv< z*9Isu1E_CW+~(*9pFN%|QacuX%6uB+VjcC&5v&AatF4 zcjrAABzSja(KgZf@UX(jUpvhnjTi%V?(*`-X(NnGbW|rB=5~O5qXEH{7kH?jQFdIF zl53$Vi`0*rR#i=)H!v`WBf_v1(O`u*~TI}uMTvNSqek)8e*DyCNCC)|$K*1Oqi z|I+gE%_C&4D|e%(E7NKy5Vvto2@N1VE~5~!w?Fg{x_4YaWN&GFtE(ytHvMz%-qGqb zZmonnCU!P2D8-kAt}zAfz&Hzqsi`hGC2Qk5M35`--Da5OMm=NsjK@A{ddysROJybY zY1(A8c()$`BUWZ9T0gpZVd1>mJw*nL0_%buSViS}?vG_ZGmR_8Ao8_U-{(=c%#hI1 zT7wR8JAUBNIi}aD;`pe@f3&{-r+?56L@B#~3mOF3eXf^1K$<%{C+7)V)~sx7W@cvo z`&0+l=%7_p9@}7tMnvCvKbWe<9LsSwH0WkrXe9l)Z$w5Aa&lG~#b3>ek@4TQI>!tC zUuFlHc(20fSo9epxB<5FH)?r1UyQx=y*%GvE3aaT5qYa18y6o}SypytbNqYbi0-K( zabp7vSc>}lnTU>*XKie47%CWUe+&-~pZq4BPuH1g1Q@0sTPFcB3M8-JGbJSn zUs$PQe*^Nx%+K#G_P-2uRndvws2T;7>m3s@6v9;1RhG}Beo85#ps=>G@xJrkG4iFg+eCr znX2EuH~2kJ6&HBt+Pu6lu`ubP?Q7(1-m!7n5gsUvjh!q_{^lmJN%@qVjERMKZltD$ zfrDpn0*Wb2k)2+6m_k4WNJ=l(*ai zD|;wV#TE4&9J^97Dk>#M1}26r0&4*jmfTv>T9!hkh?VrLTq|D((FRm(Ix#pi>^a&q zNln0v9YCvcIt`so9Aztm7TWRNWF(}-HAV}pWvf9EMXLgZGd(TcyW#qEqi+$iyhhCQ zj~~hcEBUs+-dT%_5lz<2+fRu~z~Cw3JL7|TY3SvK?1&n~JX)y;X$oqJJ-oL&-rIVn zh~Y1ae$HCeX&r3Jo>$j%^|KF=PfLVdl4%tz9lSy6QJhZtuBO`S5K0$ERF*-1id*pS zu;vS3@(^)+`l%no>wWiPg*tYBq9JmUUfOwjGgh9_2Q{}U4MBL8whL?SWM5zsl5M7H z&2M>JLoYoni=etGejl5`_@-M=9xN%PiTzw95%e_w_<-$j$Ol1(;Jo!DG&6u%{ehAI zVj2t#s2QGKUKZrz^9DeWl$4aeci?zTV!y6G_|*h)-5Wxf-95)jML$1e4;&ysQI-69 zKAGKl66D{tvi;w>h8yq^mlybd@9i(eMo5c)zU;bEUurFPFdsAj6<#@TOOnmV;88qQT?sSOMu)wbE$Mruym(wW+wNfd}iFUc4<(Pp}{wb2QUNJ&M@9)|Ve>K+xoMcW3wHz&H-3t%81A zxK$dQ=_Yn&7qz+m5f8@4yN;)$EwRs4-QR(&pUFd4QnEiR$+D?x{vVx<{aZObEr&=X z_y|rqdb*|x-HBhsmSSypm*vC5*yCAPh9I-$!L7dONOcKAO(d&B=wWU^XKW)cICFxbLj9}uR!EI$z zFCC7K<_xHWYxTuao#|foPgTr4WcU0>tv@d#FUUZ^ZKk))K*nh(u7Lpccv;x>0z*D9 z$hdL4%MC{<_uTH-KtpZ2OPaRDu#m4S{57zMptsh?4ga-3uI^Cr+dqJEu1PNv@CibOOuCU(~ z@()=|Ize8y#r@bhV_|ZuBjvoZyS~hhX(6W6fP@9iA&NH5_9z5}LhXXN$X#0;Ci2|W z@vMQ;0v_*Do2tG?38TaAHd$vE7v5+Rvr+w%^0#HhDdSDQd$&+JFCT@fH1K#FjrH~Q zKbNGZr5!9Z&;yx@nYkJa3=d3iz|QW!jtim}C(JZY=}{k|7?~IYo)-g*yxe``$Nw|b z^of6~)YF$2i%xAbBa0XwWh042`twL=D(Ix;|-=yhHAUn?zU_zd(d*p786SlhQ z3Op}^xS8<%*}3vwp09{>sPoBVtr19gr!qbY(p^w7iKZAnt^Pi4ztR0e+-Sde(-!~S zxZG*LH=c1%*64HbyTgAzAo}(V$GhFrm(tUTYB%Kkvkv|IdS2uG%UH4|_e_LemRl^F z{1)Bk`4-sS6s8ivU2yUrk|q(;o3%`(u=%8=;oh+&v5mH-q-a*hrA>LeEEFCtr@J5S z=nYb}?~Ooyy%Fmi?=?D%xmEW@m|_(=v#?np;JM6KZn3a(x+Ru14u*ef;6!RLxwVlr?q3+ip-vIBYYh=<;B?xY(Zh( zH7|4C1z2A79fs_{Qp#v4c>s_d^LTEFLtZ>rU;v;0O;scB z?2Dc&^L*$7gG3yhNm4#Y+C~sKheYbN!tYS3pgS?frGgC&|&#XQ*BG!Mvd+0m-vdI@3-P6;AR6 zrawDw*L`U0z|fJ@oQsg&m?=$PyHXy8ca;36v3Ww352}b-<&p2OU;n@ zR(ws{SX-NHaj{g$XpH&01Vtqz%qI2Y7vgNpZOvFXL-t>NZlPtT&i!wP4+qxg6JyK> z2(h>8)e6Fli|$9VZ@s)^6cj$ijSHrwtgE~vviIC4%yO9UKmHSNq((7sb4=b8q!kJ} z3^(Xvb)M$2%Mx(K^qy9$bC7PTKTnZ|_S#;O>@N|)KV59JJ*)ek&NnEt*}L}2GFru3 zcK@m&^P8|>dc4}`Te)BO$z^}TnF{Hg*?V#MqTQYm@{!kB6?QZT%KACp?_o0IUuOj6 zf02iiiBsAtWOqNCr=e#Yksm3oN>Q-7diY)KIPB=`q?oE01z*d?28b(PIfx*H=$sAp z00}!HpPHRueY~4DocNK$7>ytHUS>j|C{0k2FPF3)Z6)wy9?ALFmI>cGjZdFEx!G7X zM@@9BY5Oue#bH=ZVG7^=9+8oJi5??^k}a z<$h($%nFZ|o;rehk!Y?rd-mPGtN>sPNtjm`#x=*wY2Op;f6B~O)73v4)^h-Rc@gP8 zYp_@lZ5U4%j^8su>#c8SSiF@Tg%09nVuKmZ5;>ONK6J5aB34hgG;9^5expigkPS^G zYf|j!>_$t=Bo)qUeHZ~7@gai)MGU`oc(t;$#&NUD7WmFbwy=MYXB0S;pd^;j(ymjS zf2OKsPdM);k8nwhGL+-BzBd_=7UsfzC!ahDeF6>Rxa)-cV=*DN$77G+;vY!J{Tt59mOc9>;(U!GSRCE$f zjv>p&`%Y@qv~ffm+mu8~3z0or+luAy|LS=Hm}^EP51X^qU>zRu=>B}}B!j2J3*D%R zj-#aFG~ZL53a`Y)%B6j`_uH;5uXO9t;R&la1E1jOV$ar&zpG{|#NqfYCpYKQ>86(* zK~86UrxC4u0|)o*)8`gDe*UJDP2%Gljpk2bdmhBYPu%0fj5k{Y8a?U+a&-losi#ZSU}B}6r8{P~Cy>8VX60y3FNKf7#7*N+^T=OT)hMpQdh7RazC?;UJp!NQYjoWH6Hu|&yjCzJQHSz2T5HS5&HMc6?v z?onpJSzI{^*xyaJ?fJ2X5fS%hDe1pf9Cfs^D0_!_pNGEN-#tGpspD~lL>k{=;KO7@ zuwWs-91F4ktdegHuQpXWSwD9bo(*Nm6alA}>*M`nO(=Vw3!goso&_rwPa(rzlJnjF z@dZ>b``Mx)puVuwi?xD34#&$lqOm^{7_e)zH-h zJjC3}vM4nmeT`ELkVE%%huZ`XFT@MNAk~80QK$_AfgvW+Y0*%yy(o*{jKfIf(xST& z#IYB@iA1a`rbP##^b#&UnO+`EMtYj;KO1H&3~6Cshd^QXRRZO*Huam`^BP6y!$|^# ze1F7ip~Sf>)ByW9Ia!=y|C=3oRGfJ_*&DPz>3kL};@}$^vqht)S|)P-czd7bIljeA zF~U%4gS{T>c!H0SVB8lP(<|x|=sNu7aZ-{^m6G5@`uboNmQ2>&!u>fv(d4`bQbz@J znKdp>w~GU=f2~>pnOt988Z>gP>~)op7=xI4<5y>taIPfUBFn5+_4x~o&E`i z{W0*!eSguY9naxnxglC|M!GFFZ5fa5T z38thd=y`ad-}5_lQu8#-=%;T9S&iG?0agJ)|6#AHwBnnQf%NVL%eb72!zCz2X*SXF zRnft@WB)I6eLpb4Is&HtcmaOwLpGA%>sw2VDp^%jLP@%=Eeg5Ah4O(zd8(qCvOm5u z&pUlp{TEyNK<-ltaN=@J{ZY91<)9 zeWD*8DIU#wKbzO(elTOcnvxBS`l2o06tf36Zu{!SWT2^)j2y7r>DlFE@#SA3%k<&% zR0373TkAzYq7*EO_q()dW$U-L%7uAb@Tfg&`WX0W)7X^GZ^(!%5Q30-QCnR#RwvOm zqn3DSrhxsc$L2+6HYM@K$7TRy^+DdH&azWepeq!Wys|m|UXd?S_-6|R7IGt9;Pz)$ zORI39$UlPmez)D~Yr7}y8$pR-If2Id*Fk7TWPcg{a2xA^k5)gXM{h2FecRf?SzOfC z(9*K8nfCr~SZ6FBdwE!)u#{~N{#7z6X_coSjTUw}zfOZ}P=&BAv1{%?@B zg_nbcYmtK5Ah@c&uy47XFrb#D4*|l`pgd{;>1l-8y#Cl|iCvwN^)Yf`_Qwz7GOiIi zW|&o^ga_R1uT>p)cQ92sM}PvEHtROYzjD9vIs12$7`X&z{tz-Q7w!Nl7xVu~-2S7A zH!gY^Wl5kDd^TJ6Y)^{JrmARxoir_30p*Q)P+eJB=eJx8SW!9r{x$%o~tMdA%XrifnezLXU7XPTISO^F&Fj!)>bSFCk$|o zGhb&l4Giqf--DvrFdY8QizJ-waR$0!bAtT>2)@lbh%Wv+2%qjdNNWMad*K}fru7{p z-1{Bm8;lKTi3;B*gIGvqkU@5+bjcvt#S3H*O$$CU$jnYF8Kg1An+)Qvc}E68;R0<^ zagE_*ka2e@G6)k26{wVoD<*>+DO!+04ztE2RXg+86;KxmMSGw7n|y^We(S@ z$rBfb78NNP>f$@z+GrU?RF$c=XnQ_Fr#h462HhYlvk{~XR;fe0R={;(Sj9*R!bz?9$cqwE`%+Y&1Sc!DfuV&Zu-K648kl-p2BxM|C$VPZXraP zc(-tnKnAH6=9Z;!dpiFtscMwt#IJQzCH5smOxyTlU0civiNG2ND}?eBoN50eG6<>G zZ}X>Q;U$HTaL4yQ)>vO}ZIupAIx4ol&0iu1mPArJT)bu02C6s~b2x&2$^LO752WRf zcmnNVOxAT82dIOr0vY)BTH+T1Oz4ga`gb zPI_1ii&-=*mWi3~K%%oJU-jV-6eo_w!IYMNWXPLAcuY7e0?PjW_SW8dG((}}ap{by z4hQnOC^|_C8tCQH*Y`Ai64+ZZ8-ag9tjQ{Pw=Id|O!^k_doc`sWhBBz)@7eX{t030 zo0;gvV3q9~=raIGl(G1g%{n@PE*9q1=>?l^X<2oqe=g0(GtXfJSua*|pO-oiwBZlonYv$U$=%g>7)NIkrC!L;&ZGsXa;{@HIV41z2lx*m|x*2v`4@Ko3fzR^QGF)0(C;z_tS3lYUoMeM$vk;pYNKks!3Kv#+`{h zcQVc(876IIZQYRcZH>#81`Q%~YEkW4zxBV*^Gd=5zZ`{-cO7qI8QHQc)q`bOOgA+z;XZ$w^@Z zH!?hYLmuUnh=r`J^eh)M zl_U#^g^c3E#KWkT;nsxKD|F~_cz7fxI-1?d28@S;nu%@pUkNf?4YbcZ&G+*k;$#_V z69qNeJSyPQvgoqn)gv=ny}fjJhX>ukq$hu@6u~YLT9S4&CM_X@X{-x=ak)*0?UbMR z@YN&wWsfBZy`Z?6v!oN7>aq61N9iS@k0RQbv;o06on}xYiDZO2~!|2r3LJy}M2W58r%g|1KvI z;KaBfg2D;^c7%b7qK|b|oB$QAr4qo&#rnmq24EyR zeXo2$T|fwvfuPgd+OL&KnGz0PxusAYCT@1;3U4HEJZ8c8@`>Nw1)1&T1t}aOnAw?< zf1ZEvLDMDc?U7c*rB8p)O1~(FR+E;J6=(| zU3G&zg>8B|j8qmPKe{Hqdxq%$w82l{vp9?0N%>()Jtt^OeQ?NG)HHU1nv=GF`FC7i z0Jo9{N2AMt(k0Pce3*$rmoM@$fbCzLoJnxS-4dT&T(Yuo@C0nQdOE_LP2dN%HBnra zK9g2ob*+l9eX!Fo%RP)3xHRXt8YD%)P z&&CCW^XE@kkNeZ*SC#m9jZQT&NbpJuvhInE*h`GOOhJ``v_RrH2VuBfb0vA)kXUwU zr7Rwr|BtP=4y!8K!o5`#R7zS}q>)g%yOHjeZjj!zBHi7HbeD8@cXxMp*EdnmIrrY@ z^YJf4*n91{#+q}C_kDlLOmQ#r{gj}hZT5#EZ8bK{LT2XbBIs>>7CI1c5bCY+U7~{w zp*DNKzI~TX_X)kl(c>^7o3&rWE&coQbM<=GjXnp_AKJvk$H&~h?|GZAq}M(@s%6b%TzI!E2*W=u%qBB(#W=`r~B9XmIn$8TH6!c$%SWja}>Q&|=*l2$IVr#d8( zUut5FwtyESl_YRQ@|#z&eUHTD7M;xSAv5#N8f9!^C~@EXyY#6a5)OtJ^~1}^YG5K| zrmgR|EXD0$#8P{{yIR?4m_-Oi+frVrd{CB>FQb?WUMz(pzxK$lQ|C8?C>zDbXL^5) z;)V9`iwIs#y8DkVpfwGXhJ%!;LP$?w4Wx!Wb%l|HN%&?ZB`y7w2e$lpZ$e?3d z{7v!Y)_W)#H7`Nfv8My>IP%KMwqG_hB8sgq3T`8Otw2E6{#5OqP2(p+xxu6`6l9Ri z!U0;tUfZ(fNyo##TbwTJ_r`u;%sjy&jq8rIj{d4Yd4>%PNJE?i!Ir~cnCYj19cS?; zu!f^#i1Q=^bp)W(yimuq^&aImGXBgzdYMUjl0Epvu3*ObsQ1dfmfa9C*a!y)@BbQ> z?QHY4Iu0qYW3Fx9^O1?U+);IY1QT7s_Vy5K&{CGf<8<6QtvbXe@cndT7+%Cqak}j( zrr59kSyE)K&eB9nqsh9k&h%h2Bs$#QFj&*dS%zPDa(oP1O7D?bw$RAUK4VC<9<>hU zihQ_`z|)8bz+qb7SO+snJCFiDcXOIRYhAtEOKk`#>Mg$W z23}pOyY4a|@w(@O;pwp`{hnx%u8K+eJv* zZgu$yv)k=yvCXxa#Ye`FyP}SABgw0HXf#F!^-gR)%`;!7b(L zuf`q{@ulRQl4^xB`tuT}L%n(s&J3mukU{Qutv8oLPz>n=oq!=JT|BIrI-{(j+(}}? z>Ts@NDKZt;!1Xxx!=qPG$;Y2fP;_f(h+Na(SezTa4&r`>KG1Ohk~mmu$dMkv5a-0& zY7b}c49owdprD}ta}Vf=E3meih>OGD-b+0PZUTjeE1KuQ=?`~pJ#dF%d{Q1*nG`7! zXd%wMR8*5h9=iZu)OGCzt-v_GOt+M(2BDyF(ZZNQ-%b^kHturbcX8n$f^i~+N&h4$ z{+t#%LIMVZRKA47?^~wZ#gc)T!tIc|$$=Lb{k^o+N7rvBIUajT0MnfkXu~Qh&?>9S zEv+T3rBNRq@2{pxLGyu6R;VRBTq2p4_YH64mZ)AniGE&QDQe*O&=_X-+Va%_D>GaF z!=gH;d?dplyOJE{bq3^@62QLcZi^WRRW;Tt7=7V&z0vQIEiquOV)aiVk!di+$s!9c zom^iRH78q?Q%qtGJU^;2((kKQDCS=-&8y|#d4yb&^85PuEKc{Lrv8!Vy$OCV7H!dT z6E?6ZBmutC{pk}e4oe;)<7+?B9Ag@-R7rb$O-Wx_W)=qPg{F~5(Z!G>nXMh{7>@+) zR?6`pw=A7(Z6(PsaL76(Aw_rrhfRmP@9Nq-uo;hpTWyE#SWM7=l-tArTQ$kX-qtsg zN{-QumgB2e%QgHC>O3?z6@77esl6;B#-}qmHK}r7`yHt|p_F`+z__;wec21{j|w@M zd?g?XD-$yNsP`}E!ej&72OrDSWZ2(byvc!Rt7|7HNwjjxP<^S=rHsr~)4v}j zuv)?8ILrPfl4+pAgJJee{wAyLvP?hSagY(qu%jYx5|opow?;fok%s&S)%Zt{jEFn` z6M5n2`?r#^vam52umLp7?>(d)48O~9IruIMnE)i8M+p}rkFy*leKXM7l{<|-NI4iy zO_HN9FB9nREqsvD8|aPgL%Ik6`gw3lXK(0(y%#%<)boF!Nbo|A29H)N3B+7AHRaUomUqXbTdsjnFc5P-TvA-3uNDv4 zGf;Z?k{2jNQ{W$)dtWvfsA6KuBLImE0hpVTO)xhm~j8oCRT&XU21*u zQ=2A0QiT1`GqK>Ns;V0A#4ju&JYBCpvcz$dOYvdh{uHW;{PQb!$T!e*8th4||L{+A z3^f0^&ULfwYPc)NvWU85v$5Z7=RoAQMAFB##>2l)jJGT}OnZLE=G@fVT^P zW3(k76|02_c-YN*oi@a6{8}d=KvXS;{h^KF3ePG*Em6~9$JQ1eo=GxzT%|Jvz%P>l z^3z`+N$~EGC}IswdVT7_#s9M$6{LV7)@-96>A58- zKoe=~xl{_F$Xe4qGBoix4dEXz6ZptryrOFe#XY#+01V?f=RRIR7|-#S#;UkDmHSI` zH%{jwntw5c;4RbR|Az#M_tsw{i3f!Me9`BlL$g#Z23m9?yHg-;s0X5#^yRxhs|MUR zt?EO9&v^m70bPM>MuSXxXrxIF-{0F9bqm?w~L2ZlpEd-hjk`uLt| zyj~zhMak#Q*coO%#2$7+c7yOFmVW&AS5bLqynUq6f^z$Xzb$>S?NN1|v$eUiw7&c; zaC9_aG=koAb9cp2nXW-#|yNFdgX!qtOZ%olp`HdtH~t!(gWzADaU#zzrFHs%R^KuTM3AZn|52M zYn`J5-as^5-2BwkmF?~BvWV8!R#0y3?d@&PoUo}t`q|d*ZeC&Gz+YuKxY(r>iEO3T zcBSB!Sxw7D48oib{-2h6|5=>&DdU^lYWuH9^#P~0sFG|>R9HZ!Kc8^Nf6%|Lmiyau ziWU(Ok&CMVINjkeX?15SEeX?F+uPqWfX_4=K(YJ|!$2q-I?O;KTZi41b-Bd?r{Ul` zH8#i7&7+l$jkPt)zE!Xe@ScTyNYBhyuPL`bn1}EK8UzNVl9Yx9Sh4@sL@9-&I93y* z;Tg|=mwinp2+x$jnM$fLu_cl(MAf4HDg#_EkjL^Tr{xGhGM3_?3OpA(j;-%+9nv>$ zL8ztM?U8q3;Q@H1CMG5z<9q?cYg_+)Y4E71sFC1LwBllZMn)94e1_zO4Ih2Y)T*o= ziM57EDk^c%(ASWsbaW^|l`ue5sLpB4$wFY+JQ7~VR=zR)`af*ei`jS;X$=h-HElDK z)R>5*cno9+$L28u2Ghi&l!_qvOrC zQrZtp);gR#M(F`PZ@H?htgN!~R06iJK6;z@yHDEK6e}SFdQ&g!%^gQ!^1szM6`uIURfNLu)phSv9h~6r?QfN)BUeFv9$$w zNX)dfK50xc($j-{pRu8#bsvScsXpTOM3juQDhq=lCLh#T?6vwdKY-pZ7dS)Ba;$r| zY3dv3yIjo#`(Y(lr3(~3b*adoGTitA$2Ew%s$)B-z+hR~&@axm>4+d_)g`Ng|dfAn^aquBi zxsSiA1+Q{02|MVx24P%ZR_J1t$mD5vP~MyiW8p7UZ2WrGCT>aZ8Ur{y_83JNmaf7X zqOW~TeSLx1KN4)VM?vuF-dqh6F>%|-NVo|HwPwAVwDc#lY5EL=XNrmwfJBPN{Po+P zE0ppI7(1-YB6fFYS653x0T+Ws5u#dIL8d3q+-9hYaESKXXwRo1gT7(QhCXJVuAaLWON`~a#m9ENlmEc`V)dIXG zCoY!haJ^V2h#*%)WGcuH0RrJ)X+lBJFtQ~@QaHpZjLMP4+cKR%j!#L zUaocIWIPqq(T=eed)}N?bO=Uw4v^dc{E%Fi(bV$ta*+6W@f9i_z~UySduEeCn~PVk ztd}2=AcX4?F1jGFl9!*q2Qud;6r^^|#na@WKe$7BjR=uEz}&jB=BJ^YEZ<$aQ3cch zZeAbiz|q(8AhCW<*Cl-=8)KnDr!zV-Kr`FlZ}d)CZo0l!IPYFtTU(lIT@AvgkNvMP zrX4Kp^t7~{Eknmj6{`y4|JW*!%>v`jH5e^lv2=AAk&v|0PmnP?-Hx91OibHvp<-g~ zZ*Mi3$boaO^b(ScS(liQVn^qsu%u>EIJH0MqR|sl%}tND0}idY?<*Kok952{-DiTQ zeVy0Utm0JpcAOgGU3|($MxcchmXabdS2Hb2I!xJ1!wp?D)ISkV^)x0dMomGc`M`a* zH_s>JBLhR3xr-wfNJ_Kt>ESC}$f7L#YGZi}#QMp}NGM41imHHnvN7GCm?$f!6cHM> zy}GXb#qi~s-cHV$Ub-InD-o=?IM5#f5SKA|dFH;7Iz;M?gM_$=JjliXJR+RSK3_1lkGzjlNF^a8SKCVj|E4?L6Y{5@BDPMjhj_fTg$C}Y5l5LMq;!p2T}io14dos!~$VgQCD8@x$Odi zs!FQLQ%Wnnw>VwFq)IwIuWMcr*mY(hfx3XUww5QK}xiV~>@5Y$**q63YFi%V-;>sw6>@JO&aroM1BzV<}6 z+w%fjYtByP_IC7)pPC>(%cuB^F@fp?-?qE~q5VSMVq1LC^%28Kz!h+PnNpewKos=3 zcW*A@7?FAYY!qkblGC$`7pp>V{rhMrbon37<6Ry4T7_vLRO+@qH3lymj9*G)u>>I^ z??2VjBDm6*m6Ic4S5i{y{Idl)dW(V`|3vb8D%5C`%D_r))%j}I zL1O!++GhP0knx@?(y~#}(!}4!_Ks}0&(A18;F?cbpyF^lh7;rMzW}7V3B-m*&ZN4t`=5O?_GXiQ zyi5Io=neG+c+<#MRx8_kzJ2eQ?{3@d!DH4_AF2&uIVmzfu)N%g<7U=(1i${=?lMry zhZ{)h?vLB-506)v6OKWSK7uWekmAK4Uc<=Kjx}nzJQwDpBSRs|0>$8`U{eZk7p#YN)IUYouuUX`_pk)3Uj{2 zufSPvC5I~tce3gLY$d=k^8|wzg;$=Wy^W8s3XJvD(fD{phbNv4ge_cldqPeY z4tmZ_r@q@=cE!U6@3K__rSZf4F^1cREDV5ePc{rG?#rj_$K$%{PnZZbNY_K!LI|AC z`JM6%UVp=jYVhjTe)sBVg=VNIbZOOt2o?SM!}P1&uH68;9ud#45yVt1Xw?W5#Ji3! zNzbq)6YtL17{NsQiN6xQAhV;jC*(_6EGXJg?+iv8|T3@^>z`0#GW z(CzNHn>*JriK^50&pk9jxJmuP?UcyzmhME z%^l4%Ads%J$?Ue>dvkJce>}LiHYO7uHk8l%tdNh1>H~S(H!sf4Op3kuESdm;uyY`E z78~S99-^Qs6P7eGzaiLXD23Ld+ngHePur{-=d>N0@Dm87S`9`Pm)|rgcOvst-Ysz^zdKZ`Eoxpesb6-L5zKegpDbW zi)&y^eI$iD<5pQvzTbzq@FTun@u7Y&97N19Z#f!4yo)?8z*(L-8^h&}JYgbG|H;pN z`39PJpxc|%Z|;U543Q>lFy7Z+jwyv566Bj`|bhHd^QHYEoO8uAO+ zX{Rf%3hxf+j4L6PcNRw}6`GK-3Tto01@&K$ zeC@ps5yBZTB}$xAZ@6C}CF6tDbf6<3iAvW`H{dSc`imyB9`@t3za0&y`>CCQzT99| zVG$ivvR?1A2OdFuILL+Oz$$`mB=8@FKBvZDS3Piju6n;nP9f$tbl6!DpdOZo*3$C9 zJRg2RlN|qE3^-}aOtREwak}A&s8Ge9K#3jjzRzZe#u_5$cI`^*6>SF z>hCR)wwfQ8`GC#tdZ&^y`%47On6;p)cq!nTU!&pS(0*+ld~BEQ;?N|rjC74ToTeRx z43coM1&)!E!Uyeg6g5I68Khn}*GaY(1V9&|yii4SRZZ$|Nc zmQnZ7`f^fwQUFn0sb}%h-QLk2CS_EfbG;`3P|ZK<>xXz zSFf%<%nhFUwe){+`etzlIhE}>EjK$CLkYhWkGb9Z_i}^wy?&ZK@HaW1LDAO=iX|J@ z%15^NgcJd9kWvwZz#o>rqV#2z-CTjzrm&yb<;hLbM*ST$tiax6K3%EVjhB{?`~qAR zy2sJ&3VwsOmX9H{C`KIG0ePPp7mj6cGQ-RGz7mw{X;m@(ph=A}8MhZJk-D6wuTG=HaJLpUTVs*6d!KIR)pXzuVoob|B$BT%LY&n71@H9Yz90 z@YthS4sB`|Js$IP33NyZm4J_^5D*4j- zaDQ_fPQc*qSRR(sv5s+@r$pmz{zd8qT!CqsX}mbb z3uPm}koZ`9^N=04JB!InGD@b-d4IScA#Bhl(L1z{)at&t>J!?AmlO-jdG~C>&e0gF zuo|R`Sq)!Nc}4*!)oR@aDI%_C(DCRl=55}$y;@@HHFXyI(7omWuXe!_O($Qp+GZv zZy3AhOq{OcFUpH6XS^Yot(|EssGMFNnOd1;2&UwfRZ>a&Y>+3U`KP_5HuYL`J=X5+ z!L(;~DUgqlmGY0e}p7zSV=a8;_jaoB<`yv9miL&ga1 zi%e0~P7npZ!|@A$yMMfCKGWJerTKkrWxJHIR+dTDzLt*A@yQduYxg3TtzL(8CXq-4 zW}VE>+q0=37{NIs^nsKlKx;X{rdnt2?~M@0XM>XU1K6Y%XGnKQBxJM@NegfHEOfn+ zve5f}6%+))`@VN6DdoosqRgj#?D;ll3punpiCWJfS{4#~FPZ{==-R7Ult~}4{&{;! zpPGgMJPs_EOOR@DcyX~%YBZ9kTmo>lRZdUZvlp6RhltSgU5S*%3{{|!Ug$L&qhd~fT*+5Zv$Q(+$W z5v%3LCo%~@NXz!D!(7YtDgNwuzCB{X<4&=;*dP9$^DEg(SU|)mSN)w)@9L4Wb0jk` z0eU0QoA#nqj{U_PeRAF<%i#Lt6^NT&P*8t*a=>bL(CodHz-;q83~CgivLC>4E*9!x z?vH@emtgC%sijjeT4U~t?Mrkv?3SrW5p;IImYZ``%*S;irEFN@Fn%0HrS*T3Ut+Ye zQDWD_**sH;?Ktnq>eF2krKV3%`}Y}nJe?xMm_V&#M{D!)rG-nH3IZX6G=&#A$`#m6 zM5AKx4WQu3$tnuBEi`I7UBjvEDa)duMXkFsD!J`%p#YUvjtt(UL6KMyq4B+B_!em$n7oK<|28A;uj zY2EVL^RvZDp`P|f&DRoGH-7u z@(B0)5n1VZw7)(U)R53On43O!IqN4%)w_Z2r33>*I2q0zS)ip4P3&g9@4ynAzBPyOX~kU9je>ZZ-4unrv+)kfW~xcCSL-}wy& z1_l;ZcNycQ`6+8+?Bswcs!(0bCLS$h^Q#*V5ZNE)~1J}e9zrsC)F3_qy5jez=OBEy#L%bzC3>l zvVWQFsp*=Ro7uteoB)>#(mFV~xw@ny9@~L93-@Y;$V6&yY=Y_QnT_VjMH`_Xv-Q>9hnk!mjL1c;Q(o3?)OxnK z6XRv2K+pWA7w}-mIq$A2GKR{8(FVKno;X~l+R5=HBDzSlt8TIVu8MrJidETw$QU8m z__!AHW&AD*$v(tJR$ToDo!R^8U>TK|daNw@U6)qrKT={0vKq=}k+kacCM~1pY^KV2Vh}Uv@sw_adxJGGrGi0=t zkd{Ahjy&x9Rj(P%M%h;``(sl6S1cJXVaZkg+;6hua~|9pE(iX>b_ENs13$7eqpsn8 zH2s)4sMbamfbKr7XRP0i|2+!vN_64Z5lecc%3DAj_SF7Js%{*r$l)p6LQhwH78>~W zqmiBdc;gb^q`88izO5|-N<-V9?MhtB*Dd4JW=*KZmv2^M3^CAcpbqfdlbCl?q>Q$X@kM_{`!K{}6vs?YF7`1@q z7YfC^5R&d<%|X4>LES|Nv81FV)K+8l*QI~yFdbpjD%tc9|MxHd)N6=tB{AAd6Jz7b zj(9I&%GgUV3GlaHN(=2R{t$4sHC-cgQ;=AmKmQeE+yH@xq^q5ZfCEbeGRVntIG|<* zNRa|~)zoJ5Zh?2?=^ttEtw!C0BW@zzrBG^KcdW*aFy<>+?_X{WNd|2YZ7UMg?n&us zKy(UbuPD2?J)eX`{?yVlX<_>{qWjD%Pi>GcZ_m_o-w0i7WAXiwVaXNm)#6)0T|?&i zIyPbbE8~}v5e_Y}?1Oa{76q?_<X9L?(wd^t@+b!g%M}!z;b}pMtRj}*S+`tdIGllef5X~`=apGmgB-G~pUg~mB9tsP z7A8fV8X2r**efD2Q9pl&H7|x&ZC}Uec_^{?(nzgRrzR9qCPIOk0|`XjEUawN^S_-OOnJZ?nqNi^}8@|7Sl z?5bI3FlCYCv|p=O6FIhp{94gFCduOX!(j_o4ZK!?c|^ z_y?1;6Mub{t>go-f71~8vp$U>3YFX_tGD02;2C75Kg(R+oj7y4+^TUDL@`F9q4|jb zN5`<2S+m`R`Fs;OzazOi85q3=JdZvFBuw!FmQOjmauzD#J0P8WS5M6+mC;I8G^$Uoh9X$LQM(WT!S zsluIxn$xZ-&jPkR7n0zCO0vC@P7B@0R%&P>}AVjLy8Gx1RiKe@T*Z)e!-4#K93 zKJeFOe(kg1YOVA7pBnm4q}acSYwHha1U)J1?ekGBoZ}UL}>vMdMFz8CPua15O~nPZx<9S$z5#V1mXEk^kFi{VZZ50 zlw?4}&r2v%Yzh@+5pwoLvI#&E;cla+r=?{K?IeO|0|^t?f)3x)kt+xWq#7uFkukCQ zZ_g^bGSEzNb4@^XqrM?`1Gu-3@BOIu(~Z}-Sg^D)^+=t{E}CvYtOyXZomihR7OMHf z&9IR_TqF-H-jmr5rsvj@-)B*)qr|8SovCEgs zvo1i+=htUQu)Htnr8OpWkz$*-BuCOvLVmNTfS!o-nPC^iLwBi^@2U62r8RtVij|K& z%#mu9Lp7f_oBb^+?5h_OG@32`HP+{H0&H|R(5=&A7vGW7Mm1idDxHNLm7l2dCnmy%~fNsNw4? zHCSD!Zv}<5Ki~Qs>HL~8Q!jbiJr1maaA`c>4=Iyaohez_-D_-N3%(v8)DLb4?XtbG zq9P+Adn?c=zdIC`R`j*Otma4I^OTpJGa$D{=`$YtD3gT;-fS7otz##UQNl^*R~Ehe zX#=>emb}h3pM}4CJXQi1-ied5bX2jo;NrI zd$9;SYECDxk)!kLaJhO?D5#TIlHOp$IB?5~q1-!mQh-DqkT_7JWvOM^ko@CILU*?) ztQah|(^dIv(i#A5{n^Kcys~Ve2G3eM;RbqCBN{PXx?;?;TF-ran#-E_jk&;d|2;iU zWdAsg2i}w7r)7wrx-w;9#IQ}&Ip*k|l7VuK91D-?d3T_&{kL{Ps}Ia7>a*$~Ex{wxTfG6vl@L`_288v6Ja2n()nP>W zR-(V(5!zA8Yfx0@{rjVrsxUVhpo_0>e2Nrvy}VTdz`>~`K{`DX;Hj6A{ceD)XOpx; zQBIEgl(*z!Z}e$S+Qa$Wx&r#Gu>)M!%K84mbS@05|6yg$Pcn@conC78DDx$ym8*Ys zv>txtPAlWhwa-o!NUVAo6qO_+JuR`|JgxLH(o@|Bt$?&`Aop$$26uW7CZ3x{ZWk+u z(sbvGqPl#U&K>%uF-Q^Sxk)-YK5>C2PntWtO#T*@64o`q*=P6I_Z7F99ThR1aO?Ql zef(PY7qI7mE?kc2pEe+$QoLJjKU;bZ>tO1S2Vj4=I z6~7LkAY~3&N6~0XVEqvQ&NA(B>jMp#p=bw}<%4mS0={vH!IQ!>Di3TSbf zvN=v=CFgP)Y;*S&To~+ks9mkvEVYJ^nUoLa=PIL8(#Nj3Lb`J;t}1>talER^rayIttddkrQn>%`ovnqLg?2rV@12fPutod~l|;@jD2Q?Pf)|^& za<0gE>;au7;-m*wI?u)OoqUE^%bZ8a3yNnfL=2yl7X}-<+f@owRHfz;O0VCsO|h|L z`LUazhb)J1fHbsC&;JFF%O_F}<6_mJsfeC}y{Wp!PxZ7pxSROVLrvL5-esUist16_G)nyesBSw-d; zo3N|9)}%`Lp;X+ECm}8%}Jt8GT}c4FH4qnzMwlgWNRQi%L_#(uky~E}u>@21=;R zKxN{#0}tAFt0%z?%a)AA$tAyuerAaMfZKG>Sw`~YPWpOsDit^CzP+9{w((vigv1JP zH-K`8ulHns@@IH|opMMtS2;nc7|)FfO`pe~*!v>{dt6dXAj>hl1(V<*O|jp`sm!ouo0Ntg4Ddt;<8c)AxA)slTl>So#qUKw z_ROa#wy~fSt_iQSU;?EA>9PuI{9`eFoJ6Fxv=pjEze@?xKhN(2tpIst>1>L~q_k6w zke_kJrrK~X{0Xs`Z7*YTz1dVt@(T*g0rUzaD&~;*)ml`%R|y3rBVg1fovx7f*Y3Lh zTJ2faO`o5ZOWJowm!;9F*h2OJ-X|-mI#4tkKY{r<2%<*tFY0#rd-31Yc~b%*Zt6OR zF^djOffrS0XSU5Ntkc!kf*By9`DZ;x^_gpP@2po%2kNTc3mm0z9DHe|5SN{Cs{2Zy z#w&?0wCHfX^ZG2KII)PQhp=~3m{*#W^^xgv+xKZp#@BovbGvSug|y}~U9QJTewg<&qBRVuh@?pT?Jhl50=~h2v%~RT*Unt1oL*I*WS-+q)+OM^-p}56 z31+;wQpPkoBH+mkLpI0bDm7Xc^JlZc?3}|kpUXi!6vDHc7$4u<-bTcy>lsT;P4&0e z6Ttj!dDmUsDE9Q|69LC{pdiHsnU1*;;`cY$QH()qxnK-&@mXAVq(6bPoRE>?B{oPA zEYZ9Q;4(NU=X!M3FB9>Y` ztDVwPJ7>M7;C7PV0=vl;u`w#u)%KgSW`{WqT+4Xm#>P3(&PUG5B1`seIO~Z0j#uG5 z8|SJ@Q+&~DDv{@j*E?I7MSG1Ogr>Zzy)wKn_B=UyDGeRKp3=t~iJ`20j-QDz#TAUq zO>2*jVOQD%mhot%JXFClIrb^6x|~b_Qm?UjZxeWxBL)84CuguUK~i3wDFLl3f*>cC zG$@TF@oy7;nwEMWcM6)mIjz+P{94O=adV07;vd+P=YydGhugp2un8gVORwx6B}&1p zA*9$`XnRRgnc-^SqBeEL+$)j-fiju2>ANN z)V*zN{DrUv=SMI%h_1tpo%HWdWsI#**|bd##sl|3015d zA0H!&^uvpr?y6OvVQFa*U-1|U2u$@2!fr_HYTGkAe!-o0I>X0yKI0b;G`(h6khsH1 ztedpc)0?^qcX}5Qg$|m{u}UgzXZXiN-R@}_F+;7R*Xd048(2_infbY@(i+{zBN2X3 zErNZcqrc<&bpA<5TelatavkJWVZe$9E^p7BhG!x zi`;?)Z~`Mj6?`VW!u~b3I|`rW1_Mn<(Ov5iIzr=Oe>R%0ZLVf#XXjIpHfPf|WyouK zFEAITrZQ#ijWAqM^Jj zp$k`hxYM$ft`E;WXw;IGcwt(D1i>PXqkB*#vlFLHEf119lY2pdfM`~`9MF4e1COwO z6P!3+^T4cTb+>J4T5+;Je`@@w_*a4Tu>(fg+x@e}H?>8{xw$Q{9$H#V_6KJzg28xm zG2Drm=MBC?=x|#lSlS;C!6$pRRWOHUr3Vn zo2=i$`~k|YGt-q$LYz5UZEJE_M8L`#7Q7x4QyDYK{8xkLZU{du{5)Cn zo31Otza;*bDO8sCtLEw)KR^lbs4Plx$H6Hsw{=~=LVP^=*R*tv0mUmEEi!3rk(|}W z@$h@sWKJ`xIgUY>)qIr$lhi0no1dqK3ISa|S`vG?u3R<3}B zJ4_e*V`a4$Nl&Ji7l~@bCmJTcZ0=6-n4@{EgT;PE%KSu)xQE1?k$qLy$2Y2=)m-_h zmi47+UcP5GBjY$_-r)Rv6fQY6^%D5!$L|>b24WvKA;(>$EzQmEV!s9>0OG{n_E;{H z`Rrt&Cf7c-Vg6028Au}p<^^p-LkP|DW`HFCco6NC`d1!I7W3@3+j4*;DFVVImXC-i zs69^nMP+0jM2JR*Ai+oWrUjRcXwLZ)$$XQf*o&>+N?Egl5Cx+)W8CEcOqm`uG&rn6i z#zOCQ`DA6!V<%@d8~;35fXV6I|FPAs;=fRN^dm|2q39&R!g40XC;IK-V!JtBtURDj z449zJva<gQdn5n)s-`s<=L}m zVd3FD<|{ipJ7C(EnAit?*01WYs8C!+yPu_{a3A8xKb{@_jNF~OOk2sU4LN#G)p#N8 zfR~beW;NWDw~LV{Ltmyb&k`Cbzr=CrIKP4UGt@|36lO|waF}t?bGBA3nU<7vXKlP< zhULkVQ4)apsg(-Rl$suo#-|@5edQb*7S;`Z=aXO8O&Dc4{y+gWUu`Rh0nj)w!Z!n586ZlDy)5=er<$&? zSnz_!c0Ah_$d!!uIwWmATd8GcX7=R^x8!^E&wzgbK+c0Qvy`7c4NXn4aB^yhii%1~221b* z^v3KrYEBNC)3gyNfX8-`VsOL*De0&vDB+=@;3+>-tRv)TR)k2v$aucq7Z19~sO%%K zUIjTRDJhjt6Sn~HB`db7iiLxX_=3BNflIR*i%O;RBRzc@tvVkS6;-nM8H?tVClIax z@g3V;&fOf{=Mr28Fo^pJCR=i+-l{y(B8K4!s3u=@o91~F9mP4fZm0HgJF=X&4>Nq} z6tibGB3P}pXDNN4(&?_n&e+r)iL7mNU zS$^_l(PenZ;i{pO$$Z{?K94+!Q0iB9dKYXowPsn-#|Ec~GJTh^w_sf@u1JeLYj&MW zP=cRmaC38Va-zTimZav$t&vRF_=v~{+>tGiJRgxQoJxBG8oXw1n zp8+N>l?rq5WYlz8@QJ1O2Ecf%vx5nG(|Gqh%(F$;SX%HIgBIb9*uXR-BkihEFkI6^|^yHG^6$g55PtmWwF3i$jybHx*{R)V?6GSl{@ zQ`Wb(L;?ZQiONIkv_WQPqA>Gos0`mUntF(z`QM+&90Q_54rjquuq;G$Zp`Z)FoB&3qi|X?H1>sZC8L|Atfa>KKw~o z)F-(+7vo$^Ow5z>0{+z6Aacy45n^OCrK%Vt#a@12L7s#VZ)7MZ#3!aGD$2^pTB_^R zVRwgDnUE^}6R)BlXHKm_%Y>XfdpChnWtzcQ_9tO?SJ%ZZ*Zttng{)eqp5VFbAZcl7 z8cNE~pM8vH^XkHWt6&D_D>h)_`d|rpT+6}$+hY<8JNdYvbf3Ou)j;grwa0Y2{q`N@g(rT*#={86I<}d>Mt?5xnIY=uo_9ekQKYu$|AzeZOz;Ol) z7v|OM)jK(`(Ruo!5~B4^cTpEBnN&Ecw!1@-PoF%A2<{!tmfhky)#A7r{B5O_ zKE?F0q%3FofT6*_&d$z^NFs?-ysLxj;dW#ljit)1VN-kww1?}+Mf1Qq18bO^p~~@$ zg4h8H8g6iO6g7^+`SK75?jri~fUB>`{l(r)#lcj`M$9Lj)3Y=3xRekh@Skk>{SeJe zP2(8NjP>;el?ge%w)!Bk3-@$&T^;w(kS9;o*dHu5{n#1L7vmw+V*!;mGe61s_!SjE zKS213D9ZdiAK)U2Y5;4HxdJ9?ajkS5lO2~Q#UhdF~i?cIaM$@;stfMGE zht;h~es{N>TSyl0SyD1HRVqV365pZ(+_IgW!hT=222sBaUCr0n2OrZByhu}u=A{)9 z67rj3wb}flr)Rs~i+LST$nyibSz5Nvn<)b?-^Y;)|NviiUAr=w12Z4 ztLWYJneq#1>Pr1StmzhTbBOcMCL)}LXe8K-qkcSLgh9k}zQ4ViEY{hI6vXhLm3Fkt zs#mYEJ4J=vnE9>OQ)Rs&^ROi^DtT1HruqLd_SSJ#u1(u82BIK>vXGJz=~7ZjC8fIs z1f)y4LsCJyq`N^nBo(ERMnFpG?#_3X``-KB&-1?D@AqAQY}iZIT34Ls%rVCtGlSxZ zwZ1!-=5ejoF6UGwF%qE~MNiYFABB7Swv>chVuz%f=%cY{egpo!Y9-Y}>$XyV2WDrU zI+Ll=z=DE=$nx?`B?^@o8{vW3xrK>ZyOxj*ks= z$Q#0m{a)#3EX*yCOdNT)9w0sq=;Jl&0r&g1urlNOGA)j6}Jo zQY~Vhn5Hx!*j1Xi+Gge|rK$?^YOTk~`+@`>;_gbhl^^CVJ5R3Ds@NC}ur`wMKVqWm zo%(wA1@~A?MxxYwL|k3nU?QOX(-UU?J8rA?=}w&=4=j?y##2?>Y7g%7`l=Jg4dxn- z@i0-baZs)1o=E-l?ir{(7KyeijMu8Ih6%G?F=d>%HRF%N!jeoh8=p*N=f!3DMQeHX zP;k{|uUC1Ul8&*#WNkG$kq@z9cYmGqeksYW^Gb^j)_MJFV0`?Z_^-O;@keYQ%RCmC zn=rk4PN57H(Y3Q*klzHYdw+oGOM_^d$Haa^q^t>6fa?(j$@esFX=8mSj^dTDi`W|swevX)>Xze{7$QIhsTSNZrNm2%EkV0Q%S(!~dLb#;AO z5Y4{+{tEbFOM$XtwCMmqnytYj=xq>Z`VHno*wG!w9yq*N9)7t=myufX16gEi5K)zW zXEaRN+~QMk6~4_tISsxL=O;c9(SR=(OuH8nx|$hPAPxmB@^@M~Z4+tF?#z2i*i8%43>LX8@MjT_Mu`=XhJ-iXpR*78CkVmaaa{(Tir)Cz+Xd z_|%nlF7s;4Hh=0fByRGg(@_|X{6c^1H$TPnQ%>Z{6^c%M(;nP>vyt71Sd4Q?iQsH} z$q)WTJt<<-9Dyo{H2XPnGk7J#1FR3%xQ(ond#L0FW5N+JiBaT~6oMspQKcyx-`}UF zhCZP&yAhH25%_qt4g8kBnf(LUEvEFZpGESd2~S*$Feu8BvA_TIGCDejWLLr5Y+&is z$9mBLEEisX4&J!RMFjC}A$WI}CY{oCR+8@5M9qg65}D4$*YJd6EOY4l+j7ak0x z#>A8o6T7|4%EBVfVgLFyjU6?3a7pW)G+Du9N;M6Qz$*ej`6I@!RDm7d(p!Qt=KdfY zqx65^HcX>=0&pCv`XAfOOD?|_5V))_0jOyDi13L{v=Eh8h^?)yj~daDQ*KU9fku_| zDfZ+q7!j&Of*s(k0W{{aleKl}VM?=~ULnL2&GiVSZO>2vAqonLt%qf{4S?HPg9_Cv z?riuZ3!9lSxP%;5Fme=<*(GVQ0s=QMFc>7IQ<$eVg9@yyA!?a(;+sLqMUHnxxU{XF zs=9UKp`?g+CBCrTah50au1d9hsXP}k`>37h0lS^Kd-h8wS+BN@pnNv;*{&6im$(n8 zsVxnT{4_3TOISJRH?D7@ql-tfmpW_^^ID;xY<|hj%TXOJ(?uB)RCM&os*Fxc)6LbO z_}XM-tgGBSH3nW5%VH?GTQ(h(-*dmIq*&7RTPb4?F0@3{tQCNJAGAOlJ`D#XleC3Ro)dFnwgIz;x7 zle4IgufyV`u!x8sopwexznx1$ba;RDr~1@(4?cvrw50R$`D1#Dfx)J(tW;vl9$$Z- zYe)R)GUaB3BWi;cvq9+;_j6^3f5h=E|Nicp?A+)7q%!FIEDOO)*US7=L#4Gr38Ud_ z=;wu40)kR&>%HZ%-fT1yEE2Itxp#vv+~15$jd32Tg=PgSYpyuQ62ux5SrELx8Thcs zRIc1V<)sHRDNN$+biQzR_%wKeu_S-Uvz5WbZn~DTKh;ASkZQBFvz;oF1L-6L0(Wq5 zC^9lC*9EQe;_UvJ@W>7}ftngu+9CPwCu}=!bQ8j5s(AzB++3^|>rni>)A!7HWk8gq zFHA04+3pZEwJggf+bcEqonMxSXNdOV&dZZ%M%%rM#7A^2JYLFyzA5E~G6&R4xw&_h z?J(a5ZCl&mMj>(~PF-A?7Vr>~%V7EO!3>f?Hzq)v(rgOHQ^zaT>@J+p9#r+3uG z#qTtdygXN2Xj;9|7 z(dW_FM51S@yX&0<5$aqOzt2K$Vr2Kwe;dF_;&tBcu86PaTe&z)9ed{y+@-l)A7)JZ zRjSYZ*2W|$F3FZy%&m7@?}$9k5`!yT2TW*q> z7PazLyTwRzaL^r(_bE&V!&Iyhwo)9@xgSw0oF{T-V1Teu#DtzHGHF?@9=^+~IZoAd zSsJ~^Z71NJ739WS&z!`=eVX5A>*Qnza;=TCkqBpLyr4@cpIfgAzE|ev8A@+FqnzR8 zyQ6n;e%2f{IXPM2<;e6L%WN>`c2TOs6__MF&Eb0QIn0#WUX@j@T=ab~j;~^DIgYiL zr&eYF4#w8X%IW;{Nv6ML^Hj=)MVNEP{f#gM;nIp80~vBf9)6Z&?4tb+G#q!Gi#A5ZP)uu&OXMC!-kP7E|9Jk1W+Z-R;J`Y3 zDHJB#?eB)6p}xUfBN|vx2@ns6%n3?7t|$#)S@RLI5IbG-Pd;%wkJa;+F@mnv8!hI7 z12I#Ojbe(!Y92V-+GG~xv@*JTn{a={D>?Y|ECd@{#8)(Ks05qoa^meZ`$=Ygd4Ae@ zjfZ>jD|kh+3CgSLef}!(?iy0WN%ck~7vaaO+Ku-vo+g#JvFmL^wH?hQ(Y`*djd?${ zJ7yePnoTP9Zp$3~)W!ZzgujJt^@p|`CI0jNQuFxKnSiXwTkdylPdfHM$p|heXyd=WoCaW{({S8cJ@$BA%~BNDtcO8q3^2CLP)Z^?ZG)Gf454st0o`&6UzI$ z#U-(^&{8=0OR^T9Tf<_sPE4FKujSTjP*A%em$! z14Fc8LCPQqhRXO8Of1{owbim&9Vw}u zEE)Jo9%^dC_3=-NB#(53a?N3AI(Z}nz%rdonl+nB&Nv(}+x*wBUxNO(a9tGJcbta{ z3JPZD=16&+g)BXsq?DDTYinzPT4H{x3UzK_i9rymm=CFRCb!oK=EV_q4(8-%g!e8LokDfJ!~fjCr+ zNdQvATNE?9DfVltpto16)LC4%Hv+StntE^5?87W;#NkNw?qK;AYxZDCCCfvOWqw=i zp^N%CW=uw@NgXP&&P8tuRtz22zBJ}psW{e%{%3R_KEw}Mj(c7%i=0I?^n5$KI-`Pv zz0FGac-kglB_Mcx{+UF;4GFCrFud1}uiYfr@qOr{BxF?mFo!nrGQEw+=grVGtv-tE zQ_IS%XoC}r#1&49YSRIZ0DiO}!WQnkzh(?|Ejo%An-gqP2r%Y1b=1eoE%P%9?%lm} zyxkjKPY~b86KL3Qo_&kt%=U1hqsX8>wLuQu)J)m-D~hJ-Pjl7HM!c!Dq!mL7e-s`v_Yi?r$?C^>);;Y_NW+eK1vx|0LAIW#SiiDVq747)aa31FS^SV z%k>(*V<=z6VRa;Il197MK4|iCKMvzK|Id1t1Ai>i^P`1iTUwaMyV!xKO%F~A?%_Kz zgY($U=#~EPN>ZAipWoR@GL}X7I^mQz5bb?SFY2A4%}q4FDPqsb#Rcu^)l;CJB)1F8 zk<_`V&R8(!ug`KtdwhSH7+@MV`=|zq`^DU#cqN8N0beoqM}lKr5O`j zmJ;9VIhgc7Q=YGKF%@E)krAk+zV)a?(z-LnS1GY~;fw3QnFf>fw)K9YdM_FUC$n~$ zX@BUQZ$8zB64~8=EZxf--}q%wZ4fcm?yXQZ3^P8U8CiIThiWH)*{Cnuc1lB(9zxqC zq;RuCg6e5lLX^cPkQ32yF0tZqlQR#*Wz`^edDIbrL84M zI{$#0irvo9iT3{2fEqfVNJ^NpCV?({^iqA+>FLYOvJ^0s3R0mLWeWbmO9aay@f>m^ zsY!1dUOzte^o`q6cp0v$;#wV;-7Vkgw|_AoxBI##qX~`SuHDLby;VG4P%3rP+T;~| zB_*`hA2(4@`1r10&riHfA<|nL0IOH?leO=ZGdtir5^OFmF8T0sqrNn;2&zZzoUYv91r@*R>^@_&8V_Kc8k4J~>O;-8)%K}?(6CslV3nS@~-nBV%0m?c1$SuSe zupSez+G)zRwzt!+2AV)8D*D^l(T4nkf}lMm<`y9^$>r`F9Lz|0dnc<WfB~zcbj^Lhxx6huktGJn58dj@^FRA1g^L@_`|2W9kXTD?S`l1m)I;w^Ldb~AOk<-k-|aC z&=nCLuE$Q0c-gxnAozXSVrSbb|IV5dKW$v*+-e_AM+aT>54#11BQ2?Ik11bY-%FG3 zd+}_MbhqgmpUZ>yrVMcP|x%GD>1^xZNdZ zO9>6%W_5R%=xhl$3hHi~>Qs@|a$g}`Q5u4tBrh7ADzJaOllu^p6s^IQ5YcaP9b5h24SqJaFZaM9 zk!Gsj!QNk)|7L)0|NtNWri}ky7vKv8Xs!qs6vas)~pbPat}pB5gQR$-R(VZ2}5F8-bB$pYFJEZ`kc3_QG|G28hgt0t?~$5m2kmn!J9KorCQcd zbi6wBA%H|HTT4^j57pn%iKoPJ)JvNl{N(kaaL;0ogP%Cq4Y!*$F$vjTDg`uAb8=R} z4nq)ihl!;L3EoiV1xXG4!-vISX+Wc^Uj>sC8EfBEUd=}~$f3cduMG+o8Z}?6c-Bsk ze{JysyQATJPUcm8UEShwq_ft4UBjEErWeYg^h!3YO-P zk<-rlLup12xmfaM-PtNCpjl=qOPNm`ptSr2CV7#e95oy*h1QxykA;niYU0HUv)`8d zJK~gcX>M7W8KwQQ%ObzkN;Z@vB>38#!_K`HOa^;4f63~!^;R$9ceH#nv^_sGe&Teb zb>-y7NycHsMnre{;$m(lX??kO76$v5o}>XZqtmBg56Iv25LFd>Onl9aK>VyY{wDV# zmxf?~p8ZMYtEX)Xgly~=2Mp^k8jTH%CqWC4GJD?C#<4cmu&mKHunX(X4sW;7JfkWe=7kbY<@T4gqBeXyVjxkfZrZ9vW}cD8ojoZDg3Z8<77?8}22KN<>at*xzFC1c5MUZiKH7v^Qz zFB}(!X@}sfm*?wgs)(r_?MJP&zZ0u#IXf3T{yn!DTCD3ZaeGWexatAPWPJ$9onxL? z0BeDm^wD{~L@sG7GpkV5%E_#GX43MC+$ZolCf<5|`iIc&wu9UQR&5V0oV)j&kGhf=oh| zzXCZw|3!bUO(X?N`}H(Q78-g>r_Lin-xM4JnVG3=k55%9hN&M@b&PaRcf(o%Ozggz zsB?|tcQCL!uiptT)%qDfc7}GwN80vPPW*LD*OB`c=T7ctNJ6HQvx0_)iohz&%?L_B z`y&zfif!Ps+%L`!^jd?7`Q1;!WbH~%Lr>VH{!EN~c>*w_v9Y82cX}9ADgj5mfHZQ= z@m!2Hz{|QpjF(}UR#x6y88#$s+ga#Y+H=~NkS?+T4(YNk-W3?$>UL78LeE09iMIUo@AfLWThJbRYu$qQk$JY$iz2xty z#VN%P+h&>JpZ?+kiXQR@;?^l~;m6A&^kYBZmvn@O5nH%5 z-BDBdWo@pNM&Ze9G1>yT#>4HftLQ2@UgCzf`ok)0<$kI>MP zKWe)eo$+5E^ppM_8!`m}XyKYxu;aIb*!T#E8wr{NB7-|=tj^1JTeG9oSQAOrP8pdQ z{q52BXd~q%mDq>;9W<8;1TQBa5VLHyWac}4a@vi>6@FiyOm^W(9l~?UQy+0B!sqJ^ z*Cc0Wp4Th=wf+r#je0K2gp2fawDRswd*hVaGM34$NdQ1qRfkfgU_in9pnxq~4*^4~ z-`|WoOj~>6(#}jGX&b8WjExDHSr!)+1&4drCcXU~*7;w58qPYSw(9FcOlLjFWHTRq zaos3TK$Ld>=)-N-sI+kfv|xie$G%*Zs%;N>G!&Gm^cR>}uE8-kV;JYw)}$^<%F6Ue z)#VBE%QYM}ibKp&`l@0=Lw{{-$ZSmBym?bnQgX7HSiSN5I6paAd(_;ZJHgn}vZv$A z&`=cDXogq>OX+>|=*5W--riTd#Zx^uLH`3XAD0=FLhyXPlk=LP(S-TdpFR;3l)liM zFdl^_EJ#p4crWQ(m^7loWazz8bi#`vk7snLDcc(zdCKC};@}+Bhy!)61oitrMDWec z%?F4UBOXvwQnxe{#S+rf##md?IAnY%98vU@v_{i{m@qmxy2kGseR9s`b#_de5}DNnCtO!zy8uJN)Vts5=g$*@vusKEcdrAJ~~U-b2z&)|jiG z^ju$3Mf`S!i-#$DdTvKyh{&#HA zMWQJuGQ34v4{CTf{Jf%8F-OMeq2VKkjr@OW4&he0CR^g_y^vF)A zAoqdhV|OZUdea|RQ}6sAqJrDF`{xw@ybHy1Sr}SKL@*IA`gfpP;w{Ch#f2n4QBxPm z3a*GsNVswo<%{8x4YKR&h}D<3`Xr~2+!ctOZS?CD5RJP!Qbe-BU2T*^y2 z7wgxr|8-6O`IpI?g71-XIM;_9cKHTvWPiWGb6fDwS8RUiO5}MTEoO`~LVu25_3%Nz zb0r+`uMr{{;vAAYy50ov%TuYeKf*LVs{R7y$8JZr&fvNH>!88$!}ndL1VP(_2gqjR zDk=v-L7*tTa^(t$se<0W2ldfY%vn5q5eYXpR^{43^AYdA7N{H(6Y}eS>B&p1lliF6 zKouZ`XmFiP_KO4W$!aTaLhTycckri!lKLQYm-f9)ndh!mPlA|XX8mkfdO zpNIALiKRGy#ZH&4cV+zY_bbSszb0}h9guigh<`ObEIfSr_wTUKP}!swfDgcqe);l+ zl5=i$*3rocNSVlbe-O@sa>L)pXLezs%;fX%Fee4YpOgRlL!UMeg>zp&aVXka`UUC+ z7_CBC5FkomXJ-d(u7A&EO2lY2xIW?GNhl&DBErId`0_`AT50Kp6CKD)>r967fDg#e z%VW~2dwl=C7aiQM3s5>ia4+|Sp8h|2z3<)KSsdDKdrC$|$d2mJXWh`$lzx@^;ltNQ z+jF41%X%NG47b^Sbwo!`kAg3t*KxKjOu%WP#?I|{S7**1;L6t5+=a7~1LjdPphn_3 z%+p>eiil)^81UAuTmP&8NP9I$5GPoBv6 z8CqD75EHwtjyw`y$~gKK$^CP5lu@;qjF~y4sr%$$gB46cTvygd z+JE+kVL@ z&IF_^Gx^YXOHEBpGJHXf>$w;asyo0b#q`2LUdWwp>DgFY-(^%+l$9-Z*w6~V&&`sE zVdCK60H8eqIX-Buo|Bb%6&D0(&o2E=dFFkOm{=|I1B9Ilpy)Ksy`&>Ae}_1xv>!}< zXK!z)*mOHI^?!ssd$aXfopMQWy}wr%%7zqUtoP<%Cim`FzRT0Lp>1t#<(5-P=)jLM zkAC2|u|$^25XyfF?byrju^QZzK@&^na|5UHEu`M#!rjr)5w?XBSKHp|DDd~EFhOdy zKK=Zafq^_fy!wD5Ykzqt@ZWahsqi2yXBllKaWvB}l0KG^Nl6VrrvfG6=jWG|k#T(w z@DU|ZqeVbNP7aMaJb@eR&~6{f;UG^SfVq|&ZTptp^v7FL4OP|WUvxlz)lM!pMLTJ@ zb^0~~qCWU6{eLbFev!4n*Da??RkdgfB|kquw_6@u>P__qX#}bZNDXR$B!FGnzTa$t zj(9*t)h`bO!wZNwX|?8bQUitBd7;BUh#|@j-UdIH0Og;RehD8u{lUlXZ*GNaQV$gK6>z{suQnOU6)9|NgyX?jE$=mt@ILhD5GxY(fHqh2E=ILY+X% z90f8ryd3fj4!#vJ-beG1;Rp0|_9QeMiuNu)HSa?P;C~&P4TIrNuAAZ{Ek`+v{)(WM zF&c$1+$yuGiZh=q2_t|MdbWTHnc7033VPZ(J2^!e`j?gpi`hJTcFD!iG1=S8>su7` zKm1>N@sdu39#An79v&Cr@q>edBOaFVHa0dc6Kmf3i(^&HjIjHYl9IYjf%mzKhevGz zJ>{Tp(+(iqyPdPD@5T;C@=GKo)@+oFCu%B6OSi!um6nw`VcJcOj>i7)2Tv|?VrQdU zAq@5b4ALRckq$u1L+Gu@3JfeIB_&LE8ZrffAE~4FLdAIX>J<+U53G2R-}jQ4i%&rd z2cncsxNF(a&=J&KK>-0Zu1D6eq1@cud^w2NJ&@y3h`h(=b+(n3?p~j$HEu^fmC0DxEuo>;Y&#!NI}#Dn(l5Mz?#qKp&AMod`Di z49r*=`Z3%e+J*Fc!MYrD3~;;p(nUeMr)D%WJ8OHm`I~^%K$d}8QT}`w9)_+hJ1JyFEB85kMWh6tWm&w?MIrI?K%1Dy9kw^H`FS?R(uwvKu z+D8?v%m*!t@v7{0tKUs|))U?Zx=O=P%RPeZ6xHS_(qnb6znAzRQ^f-^9=oMm`I=XP z8cab^r6enB^t0Uf*|Rs$UJ=#|JRoZ6gf`@jE-=cJt0y$HDZy?}7o@<)kk zeB`%94-QvB-x^GHJ7TsVr9@DbLbZy#s@nv1I+G9NHNytajOMh^hpj`k)>;xhTtYx`ySYnm^WUA%useY$qutl`X?uW7r3S zFnXRVA_EuM^@Y_-AjNO+`lhoh;_6kO zB8sA%f?Pd(oO1?B^0?v6d!f^x%TAPqd=ER?I)aW3($lPQv7Wy)FyJu#!9ZsQ_H5Ae zAuX*j@G2c09Uf+4R7*#ym>7FiJqPr#-=C9W~Kkj;SFgat&QE+&o7Xl zTp_y1ilvpZS)`d<6d?8V`L0xfF2&`6@R=7(s}@rW^!x6V4-k}*l5d2b|IGyu5)w{} zgJ%A3wu(j8c zhODbwveDk!wCK0Zh|4QoU4hP&6f!IQr%zE}kN-JxZe$>B6^bj_SuM8?nU6RtOO<(l z7ANldlMNY(cZM4vk1d2rI*^izgc2Lm{(%8Ne1F~o?|LdF3vmE)mwtWv*O|m7eQnrs zeND^ExzL|FQ9Z>s)X<~i++sJXk?#2#{MB?^;2lK}gU?KD5Lb=a+tmA;|0C{&mYfPt; zmD?1Y#w3&vsz!Bw|Nb&+Zh2X{6Rc=y2R8>DzR8&9?8*Zd0VP|u*Z*-wLyIvF} zb#_;ty1=u!DWuT->0Snf=H3PYT}NxH|4zhB6RzUp&Uqw|vK-+$-Hng9O`CMDyZRUq!&JB4n|Fl|Y`3XmXRO8{SW z>%V~ogOWmspIq5Ntn}o`r@?5Dew62b`0!zPEx`T`jHFs$*AgLOA|v}{CrS=p7OW*; zy;UhdBBcE8FIs+E+t}nj`>PTQZa_}^v7JrRe*@voB%NDL%lDziudlBJiLq-Ppag_h z-&)aFPp=FRIJAmQ9qrGQVAgK9M?%uA0%c2RH}M~WnNS;zk;$yDulMy8HZp>KykLWu z04f2}u%BtcG&WvB=Eu%`H8tyI6C_(S|JMvxrn`YIMfJSTwl$? z7oWoKh)Ixh0fMML{9RyMReEpCP3v=w{KMaf8?tE)s4KgOwjT1PwEF-)qSj z*_G$4ZCF+HKh@L>$e*5(z(V$1?)m?_cVx6bZe1dWvcNhBl*DIp#sQ{5`XqtdVI8>O z+g@zqn6&OVKd}Is{QCSxH27XDj}wcpNnN;gH4@sr>2CmZNhuKxs|xPo+WFZ@-Lt1p zKLaYG{mgAMe>3Dqjopff7*VtTpk#GtsBRrB8}R*o%37>uBgG(aK&;p(E8pGw-Ad~5 zD*QB@MBvGkw#OMeRGm(~1S)!(?_gy(0v+qk2Mny#hox$92_P@i>_}2{o-q|!n^JE2D%Y(UJ2bk|uQVv1Q#rXIOK3i&P z>7Q=_KAh~YFUP1meCjRD%^+;viQC=X z<#pKyI{pzGTV%*uyZ)LHsO`I}3k;b?@=hcVvlyvdj$xVbcE5 zK#i=jJ6_?M({*EFV93zZH#7wD0trpy{E%3wq$R}bpIgj+{c_WC=3r#t)V?Uqwf{>q zY&8MP3xcVLjT*#j!a*} z-73q=Gcx`C(df&1z!4!G?k_!D0s<$H-W(ntZnFgk1!cB?O#W}6^Q#fbqc+y!oE86+ zmXZn*TkpX!fhdX2`GhP&=shq!L>y+=JulkcIV8o!(M>*}q|^tV@qxn$uFD`8EuZb9 zodx63ObI-z94M$FrgMza$%SO%Omv7#`fA8<#3P6G8 z4YGXrBSg2e?X8Q4!(R zB9tsP`t#>qq7RYIrB*X7u{u(uy1PM7(`FVQA3 z{K$l&?mj{L^>KK(GRyLShiPbeUjG6rq#FpooHuoKr(i=`-V?CC{ET8UiVWpR{tD#_ z3kvG=JMY31m%ZY4Q;-aK-N@sqowsK(m6fe6$d>lfgafIh;)L=MNm7YiO6uyz2U8w~ zxNLeYHw6X1f>}i>nd13@slUS(0Sik+A3XEto{C4D1fa&N!RN>8En^HyfWMF3&axtQDp&ityrH*F>k; z3d&(w@mWr3tkc24!J#ZJo(B&o@^-XYL}=*e)}u(MmFi1FWo*`VYZO2+(1|D-Q74P(ls?j3KLA{rMZeRaw>oKS^q zE%d7P{lm(gdnjHv9dPGt?iB|Qh}!9G+^pw^Iei%iB>IgJv{!g}?%!|v@uPCk;e}Eg z$bdmBJV}pSnUi%+P#jJO)FcwwvV5wp?k!cyHz+8bosCpelAy^stp(v9N=}k(Mn1B% z>ge#WSltHg_fjXR+|%b<8oBLibN2d7aw0?k@*tzCd~5g3@Ct_E7XNoVNS@Kz)}Z*WW3I)On{h!ojo)0p_p0wh9!+LSOu`=z-wp}zkH^pH3@X4 z(*-nNg3~}AvV>RvgpH&(Nl`Cd(mC3C5ft5M&bvZ{procR``s$HoENYERa85?oJ|eu;6ivc|KU-o>r7m{2!) z`XMs1+EJ#fAudRndv*SUn zWi9qGQBgV@BH(!txKqpfLdM3%VA6D;w_AL4Nl8h8IJ6!pxq~AxT%=9{(BYis~CafO!{zD5h&h;~Rp$ zf+~=WK<)zM;o1K`L;6syLc+1Q28;Z6Z+;|6=RG(1n+s6FSvYn66|C{O(t8-~z-%u# ziyVQ)q%PS$!c0x=??&eOYalWbVWn?v%>rBR&p{K!hWynND?^);`lgTUjY_+lLyCw- zf?@BDC4$khTUvS>iItW11_oJ=5%1r=eM$QE-Mbr%|C^gmVf!oB$oJuVBJm-%K<4NV z_T{0}8@T()4S30sEtAZA$It+YUK;=&lu|!Dc?x|4z$v%_s$E?*(~3w+T8Apz|C|&G z^qcX+=lyrkZ`rWwbW~|5Z7~_A857-2@@+C5gcu1Bha(XbwakzH{-3oD%)+*-#1L`* z;e~5Ied^l=+kR^B42nqqNGw;qfe9F^wwiehddv`##E6J8K*?B-XKDZQ&5AZiPSB0u z&y)Nt+6r6md6Du(!Y7C2fD;w(vEfGpdK(geB2IRf1POl2@Sig$*I zAuqBj=n`+0dS3Mkm$9la;Vbf=O>Xg_3vjn~c(jZCUl#i0u`Ctd?c2{bGHEGZ4L$we zoPo8Cl~t;KzVdff{q}aHGu6vCP@_ClkXwByB6yb@T1E-Dy1J?se)2|gA1PEoNYqI^ zy-pha@oGMY?Sp@4!&q$Wf8#|ISE2vB;EDpi1-g3ZSIMcUKoaDyL#gmzYab;?48Y^U z!h(u@JfNJPV`KkZHk}f*o;aVl{pZ77-fabz0Yk ztxbHzhB9iHgpGpt2_Kj0RQ37_mvXlr(&^p8M};4M9h3Z5eTJ_48ZPXg&97UF=k#%w zSg-C2=XVSG#+}xUn`WQ8+Ri*i^{H#gkp6m`b=bs+2;JPPPE%&}MFKVs9VMkwQJ8{4 z>!fISpqe@3-Jyxk}~>mfjVulIZrWux?S#P214w z7pEt&3&d7cRi|vz$^i7MiED4S}vlBCHWpOKt^3Wn!0T4fmOnw>obCiJfYtZhg;g?4wgQH{O8{nv;Cc#mrdK znwuiQR$Y#~mfR<$>>ft*`0Q*dKUBWkBG`-zB|!e#7sVlFXF5oul4EKO_sW^Gin3<@ zw&8!SjfNCT#5HAGS}>UV?(3$HcbPAVj*C<`ZUz#Vve~`Qd3yWTzzH6$dwNDF znom#NdVhFnm6gp!MqQy;ycV-OPT<4`Rgxw_XOBlQ)USio?Xnd&_K!4$)eMSb#1cgW z^tgI^X&c4F#PW?7%`f&OsABKSjV#-*TxEX$;gd4HLb-nHmnLHWIX-0_3u)yu1LB!< zubFi3v3>lr&H0kLaWmwa(MzmRwC!=@7QGpV`JXL&zVW1r<)hJf_xNhpV1}xa-C7r3Zh?_sBNyW5 z*0tz84aNIp2)`@!F2)z$rlO8&&mYa)PlhsOMom3&wA_?j({HAwECarUTYco^it=)J zJ&&Ky;zrWhGN4Pfo>}H6c=gwyUNIi(9pCqWzS4C0$SgnM%C7!j;01Zcn+>1_VNztb z73os7c|CAZTBe1#d)Mz;^+3o@NlB9WQ0VQ(!Ih7={bq7?2Np9x~!Bk%$x6L?sJ^5D#_PjgiC_)&so7Q9aP^;*i5lJo-n?Uyk)h4U?dr_mUpJ90$ujXPtZ+(UQe+NNjBgi`{Hwag%ADye z^(JzR?ob5ahMezR(f5&APxNp-Fe}kBHX_>e>PgQ(I&zu}`FPcNlJQ5@DD~NqXJ+;R z1CDbP0?+EG?HISL=I-6uGHcWGb`-5c!zFZB=qzra8uBmQ)op%_fH1{Igi?l98SQEd?zwDC6*m zF)%P(CyJQke+HW<4pwMqPz4Za{N&o~6Q>Y#y&F_!DD1$LH)ojfZVX>-&~o^sGv2UD z)c5dX>~M0uZzZp2ZEZrp4G)pj`GyKs-{bkUYl=rDaNkU#-6s<8UB~YOqG=ifQmd0Yo``BXoV2`KYF_=Qw3GDI4aFH_nIaI zuEb}pIb$X(tg4FBEqhZlL`2-&5Gx?6GVgPL+0L|w&*ic@&d+ET*Z$Uzb*P^e@;NcD z9rT=@ceY6%Z;_r@~Ps6Bi zJF!pAzhk-oqSryqbI>FJa^I6Z)h=LHoqMKt`a?fF7olQ(>RS& zwMbzNWKzjlzGWAugvy)@p9Ee+nTd>&pCJ&l10|&_{d@0Ma$s9(&>Fqiugb2juWV2& zA(Ym7X598|?n=vPC_di#eMT`Q&DPK5TE4-Q94o^Le<&tm4ItPdm$1J+v9btLH50Au z?Xzah{$ka1H_6R@meWE)@zq&&vbzb;M}O^>6plQ~OQ{~RkMkLK->>xd5XH;Q8t4qv zy}uSYRKn8ULHl8te%SMQKM;d+e#cU#}vm)+*J4`RQmrtxSBo=x-OtTY}Mi4tOpv z6md!V7Wvzqk}bUi;q&X)#gJGI`fl2Ys-&ul4~aM&9N<*UUcUT1SprOxrKKgLD`E5- zfXDs2Fzj$?kb!{#fzWa>$3RC1Rv9W0fXvI}-)-B30PFVc+t9rZT15l|1QeNyxMeA% zO9=H`cZrc#P=FrSosQ;;q|mMr|u&9F7}6 zNV^02bw8TWR_yI{Uzkz_2{lyQ{(o{KK5wVjo;-dmnar1n9$EgWwibB%-oagc87PGF zHHbWut$TL+ZEUQXyZbpb`lIpM-r9O%HSL2PaCmftWPTzOKz#TSa#xT#gZ76Fkp6)X zP}n)rOpdD>*aDXIH}p%ui~iHZ?*80s;FUoXMF=XDW)&#p8+;0l2=6=&pkC;T-GOFT zTcUE%pfE|<*ETeq2>lsw)M%K5gP^s794I6cdr@I}fYDfG;p@jg*C5ky^qY{~M97IK z5qaxr?9TBPnBkdVVCa^S1tx ztB@^^B4RuZqZw#k<{RBnlaj3hrR$=vRHHs5Kxd-L#fzM3R-1}Q>JOK~<Th)A@9a-%1dBjb2Iq<3lao|UwG9m}HR`+H(GE7avy8rD zA1n_|%1m99V*43R94e0{-;a?vuO>~CrE{(+q2W+BZKuT`?&`<9YXCDPk6i2pfS*(7K+Uh!Ss;9X61^{e^x z$jGl);M++LMqWO9=8Q}tb?*_eJi-%6FB?AE-+#G798WLfTc!&%Og=nG>Nqp~b|B&~reV2livWx5F6?n=}Fco+n%f?=%a9eEe5yQ|BP7A8utCjP@dw+mgE?bTY ztDgop!?PKQK_~}P4%^RrNgIl$Q0!?u@Q6rA?}YNx5+PaHx|J2@122=EcOvTt;Twue z49(3csuQbESaGm)f|UgB4LN6Sxy+lJvn6}RttlVsra({y!Fda^k};e03ahJQ6frB) z+;D(!hBcJrM6<-Py`R}ItY_S4V? zy_lQd8N|9h*J$Dkjt^jDVmrwVT_I2a-v)9H1bbArxN#GBB-7Ves#uG#FFrO!x!}6+ zZTr--@Pl#g*phpJG~4yMKybi?Oe&SVU^t}MSdKB~aUiASpbC^HImQk5@|)qC(LbPo z94Z!fcX!DisfzjI=2w>WT7;dQc~};e9&dI{h^#kCZ=MV=%2xI$fm8N8mYDu84Gj%Q zzf*^ig9mj-p*izO2mbYy*B48fX+>fQDE~X}fB?#JC#WeXwh)tBTdiDTReKfLALHxm zYcKGzi^3_Gu)=`!3k&`=TFg%9Q0$?PJI8Px@S?Ywv~F%onZS*@p!4fg(yj7FVA3%z)$Fw?F|fux#Ha4ym{kKk7~vmOltXD6PPgE@xaXO zuxDs^O@y3IYWMa%!Oa~~NQ<=r(%qkb>Zv7!f}_G^^0SWM$Yo_@&Zs~Q$#r_H3rYAx z5V}?^L5z$$(fCMpk@qnQOoT1_d%Vlf$38316P=3Ff%kjgbCaz?{vWr~)PDMO?axJ# zBN2P8p*Qe@=(nF`)7RryH7n($MW#Y5MTW#2ugn33B#);Wu#n$j*LU(jf5Xh?ld28!61*Cg`TI_nwPtG*ZrNngHvIf-R)GQ5wQ zmDx*F4jKh=)y9`us?>A5F>7)Xl@mAm8TPw|ke{a9dVf_CVjrTgiz0U5$x}Nm6_LsI z#-dlIV-QScMr7nGY&e#EDG3RxP{L>0t?s+urRTtKn38g%6QD`_(+T`>(<~Ub{|iN5 zCp9na&yAT1P1^6utOl30obcUxh*TeQ#7ImfqiBYP?$w|IK{=BZh&p3waWT{CMr-PU zlX~EtNQjA<-iUWsL3D%iU*0_1brp*9MM73ZqcF$N5bK~#tTe(-BhQb(a$z_+=qJk; zrFaqE{0L>4Bcyz&cTE<*ejtjv4vH)F{K}hh{V5#}j2sOJv{@38hISR({S$9*#z_5% zLv%uc={|DS)BWOeI?%KZ{rvd}F^0YAsvdY21TzLfyEcswf?z1z#_>?M4{yY;IQcKQ z3WAFEiU1j_bu+QW&hExvXx{&yjlCq{%$F!bZqZMd)x>+9J7%WM>O#8jy#O;x!z=EMpa`48Jn1xb-#Tch{J&5n1b`MQ>RpKlA-^p`}om# zgB@?i?usGn1~+chSS&CH#K->#=C}bN`8mUCe>_qF)}&k#%ZU*XSV!4gVENz*#_oXs zeFh8wwn)^~-)h(9i`V62MOl#LXB;s78WJ8`6kr5}JovV%F6a3#b`&#mb%eAU)jN&v ze#e4PS9c*NtgefJk#Tk5yH9gzk;}5oi?dNUh?K0Q`yl-&kcRlfb!jMpK%D8z=R1PQ za?$P=S9R~JfXc@OU$ME|sf>7K^emrp5A)bR>$R>JRFCzG9x3JYhC&!jI5EokE|B9M zw`>mmybB^Y8|UU(Y@JMW*Jt3#Nl#BNkP=i?Q1GpfKqm{pUh(eqYqS_n@D=$dHducf zAD5984498q@+ZZ0@>AAAQH>JPOSy5RwX-vr7(^+IdFWFHS+yN2z{SfJQ_q&5)MaVG zA>$Ly?HD+8esN;Q$!e64{s-1^TsvFLcE(eGf{x-RrC2e=Y-4mUsn;_!F%8Me%zsIN z3F?1$br$9Sf6R>XgU07biW&Rzt_@nVtUbw5ukJsfqbME4PB%B&fy6ytzb)56@_FQ8=)DnAR&GH6 z!VVGy22-C_6Bv|fdlu1*d~`$z%4H4u56eG=qV76UIGN-~24aK+2r3aTCXb5neFfbq zs;r0iH2#XcjvkPfW$66N6Ca|L^XXHQCj~-P)>xIrp2b}DQ(#$|hVU|C;cLBG8bK~Bn1$XEW+0Qhu z2c||3ya})-@C^dB=k&}QoL=-&DX;N9`Qt|o@Ht2DqgGikCI4dh7CCxfflh*?=EVfd z(W7^L6eY|wvW?|)*h>RO6<~pdxdLjq!qT?-`l|r}FJ6c(1J{n)QTgdpYV!quCVARR z`mU~gB7!F;qGBJD@7W`enTQEgDEVfG*3B8J``C}Ts^Q?CosJyXe&EL}k>gaKh`s&W z;9wfwy%{(B!om(wQC-O(<~w&3I+U+{eL^)VX~FPP`zc;mW{HYia?RUGqIOJ4R&fF&J;1)^fMfsw_ zE2mYR5W3P1U0i!-XW*ABf`U;H_576|@r6Q!{Q2d1f5ALL`r`fjJ5pxPRWC2f*drIpf3QXgUtdmYYkVl<^kx=9@)l`x>;Vwlg zZSZssb-lC6X+TbD%#R?CC_1`?@O8XQx9&c-bZ$xE95f7t&J=At$>g-O%&8Z^ zZeph?F%=a2fMN%3MoMaG(TKP5-mt{Ov{Tk}i;9X$!E<4U>-;w-Q9C9BVz($qH1#k zJ-tl(DgD38Uyd0(y#_NoyWhSdee1!|-r{vEIf&nJfwCFVf1fzfj~^-|@FF6j7)m3c zOz`-i-GjaWkF-JvR5VlDP|67IL$w{V@JzMQHIW{yI4-Kv<@Iq9IPhqQ7+TOJ?k{kH zdA~Q$`qZB?cGH3}`1f0=P_je09rmYCq6`Od$`FYlPu3hk35jYPA*g41N>@!O91&d- z9~X!A5JMv*#l-_|`UM1t2nM5;j59YJt^O^9(Nb4`Stv>9IY@JH4o}5TPTrE(j^+-R z=uGGiZL>>)QMDko84lg-DOgn1b%D@~+`Kb;2og_1UO4Y@A$CXx;6fs<{5Lh5$OskN zPdbXvrWc84*8=J`uz>b_o1sQz4s&zABbYIfM!%&cL${dDZACsb@$dKu3Y#fq;xYJV zH#E`%0F@Z~tSl{|t)!SJhCRmSA+!{#42nEJY9WGwLVM8^8eFI;>3d3}gdgAp97GtP zDvEF@W(la7*S_-Q4s`D2Hvz1`42-S!)~#Dt=^$?3&%(GuUK#3t1JyNgip`L52PQNW zNw5$OjOX8=c5@K;NGF+a3*`oYDTp~?hWyX#PB0NHsjU87@@s_bui%$2(VdSYDHG)x zGUXEMzrsWXi6H7+C@OKvA=Uu^fVGxZ=vk9$6gZ4WV!O>DR4a)|`~i>!k3@>xC41;6 z0Y5{@3=8RdEVw1>;bz88{+3-1C(2E>ZQF)YBAygvwk|T9I-w&By=W;UFaJDO9aT6= zOooNnRg^ockT?1(W|2j`di5;OCa>C9nA+=eI#i~Ju43^WF0X|l#!mE}zBYksQ~ysj z^BH_!(WBo`njT`iwj+SCE@sChZan~rk>(_Fi(8mcA~AY@j-rj^;KgTFWzeb4 zbbVc#pD2cYEqSj`w0KJP!Slv@m~&Ax1MgDMgT)S#Og&$xJ-}TVqhjCtKCKC9$ShkO zYRjNAd`4&@?%ZmB6}?v%aZBh-<5N>t2pCof&7p$_e>S&25IB0$!wn}pno#ufDD+Ra z-eLQkntB>_;S`G}Rs>o$C_(!Ihlhtz5)}Z@Bb?Xvv1klRDh~j z&N!5&ZJvR&JIL?KLz%9XH=Vz>C)^O*=48tsvCrI4lj#0w|9hXX#b%P-Mabek%sIlN zPY4`>pjGi1HblnrVi2ntR>=z|c^W+!Z83XzKb>c5K8lhJQ7GXfNB($!2*P6=s%ITf zCFvIPu`rI2n}Um(AMLQ7>MO6WuZN^2iVs{30~zgw(O36lVvhSgp$LXh3u1YRgX(!x zCZrDAx8R~&%eX%M9hXW5UCh__|43{p7q%<1pSnkl86d%wjEjPt+|Ofz zj6bl0C9eBhUb=X(zGLdC;q~iOPGUIF%Tvn7PsgGi(0+$D>7k#-@6Y|`Uw2H?@#){F zTu)VDpyGhNv_S!|YQ}#ObnAjSB#mFgKT5pg@t6_1EgV---6;T-Q18di)_h9U4-&&* zoS%1&aBwKMUxu=VQ5!~qT1+ar-$jnN!0H5^eiRlG;i{R7D?QKe-P=rmz~h%KiIS2M zTs*+`@Uf`&?-x6efTz3sfir`=pq}1Qk=Nx}3ghUBxB!mhbojSqL4f2g*NzlwucWf? zYK=@RaGER*Z@7J@geAg=CV(0M8Rx$}y5GtHw$0%}tGs66S0i9F&)iMB4iX z3blS*hOM4%>T6v251EssPzdG=j68M|kob1E2; z!opR;rn!GHf3F12G@S(Kxt8?)b7?2`g1$d_=FB)w&5~hY%*F(RnL?5X*0Y!|AjuzT#VlqU~JV)@Y0z`Rn-o*F$^9IB;fGE0VX13a4 z6fyN6_qSsD2Ch~1d3N@h`T1T|YN0}ly} zSQ--}(vv=7ryx=Vvb^V&6gq_Y-dF(+mueq>5!?4c8A?9{}ty zg`Y*(*du9>b^i$!=1*yZOv@o=mJ1hdBN}7&f(1&Mjh)|gD|K?+3s5Um*G}0G`FVav|_W_rdXEY}D^vOO6*M0X;oE;v@ntdH!SO_3+`V zoE&VUMiXktH-&vUE-o%V-!X{<*Yjs>YT0FHE}MJ$4*kZeE(1)z<9mZ9Mqz+PImJT*D_v$vSP#SI_~ft|3kGorKsZ_E4~ z=EQ)T!e1h#VfY5wdJp?F)($8e>~lr>wK&;&@go7Xz;WTZG=t;c_nm~exH{$ljm6@^ z5PT8$Ze5XLYjuHY9;(?eWKk$7yfjTTgq7oOhjNE3Ij~#sqEVxqO6dU_8tn1&W81%B zV#Ap+QBBRR*498A37Z7AZg8XkeB&dZDx60(SX2ejMp;5bqZ2!OPIfi`33%v%J7!5As4(aKtO&2bu&)e)3z4|+M;Bp%@t2n8Ob|_A%@^ZNXR;@hUl`u z2Wp{}hY#E1dCNq19y{_*DJcUGlCjcg$j8=3J9L!1IoVZ#bA*Q%tfoN|qyaA)Zf0{1Cl zYudW?zW?3A)sPfqM8GqGF<->TAJd;Wu|pGpF@q<8!c3Gpr)OnFsCdwAjPxtrtztw! z2dj>V2a3kPLwiu~SaiMdPVk`^0xRT03M8 z%xr1oo0x;ufZ}6jwH6 zihqpH_oKK)8&+ozSJAB8q;{E%`Cut~|PbSb}L%%xVSy14d8C+5#q(dm)vHtVz zQ{Z^D4@h`~oF+Ujta&r~?#~q_J76XPUv%E2af>3^-yT_s4&v@vGU7i{`LquhbVdt;B|b)Lhq)Yrzk73SEiGuxxy-vS{)HqqwQTyg zQigwb@=3nKKSsv;n_62xfBHo6;9lR*yeR|Lm`p9RO1f%K!O>J%MMU;T!=m^zG65``q zVO}8K;o|IkzZzwyR~I~}=;DuDytsfyxBPIpukUI?G{XiKaLk{PH|)I0J`SB?SHzC2 zV7()qGm)N`_jl?9?!@~CDyXmsabKGfH3)dY08Cxxe?qXU^Bw2M%?u1mbLW0Ge8;^+ zOA^N8kQEr%ioF61M8|ei|F3{O>fE-rjv0X53p;OOWMZONrq-ez2aaAmp8$q#Bc%ch z94F}yZi;2-7t+yNxvj0BDr`^&Qh*#1$k+2=qXXvT5VF2H;Rf@l%`b3&#+Y)sfbKMm z&t)kWTRDbiY&^aTk${w1G3o8v(DUx7Yd{)!j}NJSxmb~Nz#OLA8hP?1;F)R2KD z!i}b&$|AeFxddhX;~Qrr><7Oa)s||+%zH_HfwEZt+31P}EA;%jD;~V9x^F`1Odw#>E;wGt$`Ua(F z{0u7y*V^>yPHWYM>0A+OHA9o6Os1fakPl2XEiGZ#KnJwYObLb0Z~1Kw1$p{go9V6!C=TUd|d<>(TM$ zxY+NHzY#t*;WeoP3F=wSj3cKwb!AkpsHk`n-m`g&g2Jb$Br}!UuGdM47?j~>hi0b3 zthuFS;~6JJOWm0q4Opg5|MHUUy1?rzgv4w#-5+b2ukP->b*~Z6-{-ozjL{2d6|-}5 z|14d6%YX{tPYz`cjl*0{M8Dk{%{!oy2r}op*_JHsT6DftU}%!&Z<+|cf72#q>EIUP z+ZSwq+2$C0_nXLbAUF~3m`?myJtg|Nd%m)lk6aGhmO6a%R(4UV@Mh?#$S= zaorbu+gIUgR)%NK5h{?{l`d;&cx~zSSbb{dp~Aa*f8Pe9BUQ(C?ttJ5QXoQ%4|!Jh z`TkwIW`feXn9V1}Qz{HRXTJ_;&PS}c^!wW1SiT@FN-|tgqpID#&Sfc5kmKjKN=@`` zI%_6Pbi1rJxy4vdkDgfZ(*ozI3isni<_~k{Mw?CLCguv9*M?UO1}z@t&W$%-zgj12 zzlboh1lHcY7GrI-Np5zhbogAq&E+xAmud2kw4|#JwiY2~#2to=wqlCGLM%?w*%KSLmu z#j0zCm?S&KXlE@=y!xm%)hhB@G~@t2f=}^;*ORBz7OO*DnH8S-1qGA2W9KZz`_d%W z=L7DBQ?b_R7P0W16DhD=T-ll5NmH=$C5xZjuc=H^)tO&jQ_(wU@N`G0@0?J9ZA)|& zjVV(B&vK>r(1DkIX(?{O$;qi@HSDfxK`#T2_vDWc9+2>wF5t2K5FFagGu7%{{K+@S zY0={I{k)WRH#1ExQ3h2<=0ne)GWT2-jr~5&_A7AdbdN+~9N!FE7-Lw5Jv08z%G#tH zedA=0gzBSoO4cUI_z`@I>Wg%C)~46Obbnm$vl%3-A;)_p);sHqx$s4WhGN1eVWnJkD#sTOu4F1}WYRSk5>J)`~=JR~`8ojXvO$v%RI8nO<_5QXusjN6 z%Ep8uy|FHT2cwH$KtAD^opzj}4+0Y=7F$ufj;S*u8Tn80)7|QAbl` z{w=Hj!g&D=HwqV2aBp&Q3@4RoELFr7SnMyqSGcMlJX>jPy#Und_p_h_<2webql6?j&zfd!*%nC*HiA0J=S#hZcmm|}9Mb!Rk_`sz zl(I|=jzTcg*B?JBR3W4sYTib6`1JXUh?GR>ipo{;XdkFau&aq#h!2|8QZ)Em08Bpr zuuib2BZjT!!v{V5#!8W8pTB%@LGBCSPG>(50`;R1t@($_%Zc8l6LG}5#q2NzBCWKZ z|NfCazeF0tp>AYgL`NUX02@+)EFJ_!(aVIk$BL6zuM-}OW!M9#jJ&Zpnp6Jt*R{xZ zo-Y4m-ByIeM|lf>4(>1l;{!VH^j#tzW4NzbV4n$#KC0+ql+F5P(7r>MESmSlD8i++ z$i-{niwHYEj6jj>>xKzO5qRmeV@9ToRq_G3T1;g`MPMs26 zrp}Eueik_E-(XEpM4`0qL>ycCg^j1-m)?SiW0PNo8I7Mn{TOp*6gN+mg|fIcg> z1a}H0ed!6#MNGR!ZxOaHM}a5-va)o|T!ONif}D5=Lq$HLFEh3v0IbqVO+cW5fng~U zCdcC`D=AGnDH71@JVO$|NG~NL!{@rP04N%WhnfgMK@)!o7K!2?)?LlWGz(E7shjAT~b(a63JGRQ#` zAq1qU#34yrs=1`(UV{5&9UWJ|!sWqJQF0Cxr}kWtO4AtNqALDDP`g5V)cWL%JQ{ao zM!m)GSR~QeJV!&HvO@E>t8L!#_P2p|Q=~paON=3*1rH#Ik_-_bCZ`CYSq;)|rm6u4 zDE831iWwU-5MtgCIea`lVSD3I1a3j>ZBHVii*`(iDBP;Nq9Th4n7z0p#=s&xwdn+i zR5aQkW)tqbh7tfYJ;leJ+P^=g+a=(EN77@J!MWs@;G*thLh5jt*-tjS+mFG)nvWm< zl2dnlcK#OdY`Ww1$j2uk4qf|O1+=p5C$0>A#o{1%s=wIvg2$Bsf80(%PA)7-{m55T zTukijg$q9ny~-qHIMw9j-X|+z%!rfiv3vRnRonzH1xRP;C0j4D9FQ3o#P2ER;BM<$ zPx*U~Dtl_&yw$A#4@;%>?|41zWiT1$=aLbh}AoyKU|O~l&ouR_7}f?j6!v3dASMUDyeD)NN?33{kLK< z;#Csp;_TV!Y5f-Aq(RC(dnWVekS~U?2ef^4*aO?S`JS^qEq}L)Q^3+`&1Uj38DX=+ ztNiauW$?Ir_aCPKc1i<7Ly?Xi3>k&!)uZ~mL-gUM4J@G2rzs1_eMZ+e$jbm01)k9! z9vrN^K~TKyY{eD>o4*$a8cV&2u;5?~;6@BbW~Zk1GGQI6r=qd6%0z-Stp;QmS;;El z2DZQDfCoH+0YPAiLj(~NDzNv@=i0RY?@D4&254oH2d)PQ&;`?GF)+Vq^LfHBa2YJ! z9AHO;13iXH5hN`_*nR=UK=+Qs=8louI3x0a%fY>mp;rTtbpac~g~?vWbPa$WSN{s~ z9)A9}AWu(u81bnP6pyxaKd?D6OxAlIU>ctsoEq%F8+dUf!4x1L>Hvrg15@;uqdpR* zZffV-Sl+q?1hE|FkBPFpJRxW*!Hc>)Stye1*tBzEP zzxvuF%t}b{r*(@~w55X8!VTngBopS4E7ah7hQ8&zas3^Y zq0S97h2eL~oF8%y`Lbirt9bNZQEq0FDjjFR$B+9gwBKC|&hkCR>bBez-#oi+ zGwMT^keI|YGu<7Ldmh$)*NfWV3d4c+DyEQp5fH=>yX|HtK^eB=&s-{ykINs;D8ior>QIsab^ek2@C7= zxGeOUwI%eW#ca2;Xv^QpI>GC>SW#Hnmv)?Q1V?dr5PI~19xb z|A+I)bNi8Ju7}>`+I<~3Ze*cVuqIIO>vhkVldWlaO(knLpWQ&X{m7leX3Mv2`v(9d zhUVLd76e?7C(_|_8t8enE4N(J$V9Zw{x$US(`kfYfkob7TNmAyT6-Z)4rD9+?Qsid4Xox zK~|?|`QwesyEFJdf;ksV?Jaa}+s=2qSx>dUA-TMy>+=)6TuEfm1N$2@6|H`vWM2&( z+>gBD=SX975A}Dsqh~1 z*8L&<)o`iOYprUZMN6xa?$l~qL8!m)T5;TZ9{&|#ZNnBaKZloV&tu(~$}tz)UxhWi zy5_)3b#`jAJr>FeN^0{nHaci6!rt6UvrmByyTXv&fs2#XqI!j+FD;9WEyOLXwg!?e z^5SJQoDyDUt7)nH2Q}T6RlI1#rGn>0UDw>MpYAGfev>*{^FFdaB7mgkI@8CRsp%(l zt%lcE2kWXkPc{VvP}n=n`Or48vz2R>yIuUTfgKEx`JHc3c36C8=tfV!K1jyGyK7c! za{8WbZu!ZT!8&4I_gS9l>5|?zm1Tsts zlVkqNg0@BT>rt>}`dooN0x|2@uo>g^0|FPU-sfA2?Fcq(GLd z+vb^1puGwTqTQMpDio1wVxb2EHuV`ToVnW~fzrkPp11=Yhz1;b zgn25VY$>My&RDT&DK<)E;L8+aDR7t5@JeE65G_ml0jW$A265Gy zxf0KrGH)d1{tS34k$kX{X+7P;R^n-Q_`(VL1ci>0X8ZPpLfdSP1I@%>P6)%jEFS1w z_uQB2D5s!cibJo%46&JDe5;Z-Z*@ZYyZ@`Aabo`&HJbyBeAoUSNt5(BW27a^=CZRn z0l}PaK3D4{v8r8BaTSb`68wj7RxR9NIO6}D>ElPKVg%(0`y@~$8TMkM^@|5P? zd}V&Wd#QzbVq&#_`~v+{CmN$3#0Wn=o*>)7c{afJI$+YTENG7=GdXJ}1HZeeDB;)a zAfY(1w(yiA2tHZl0yZ);m}XqMOb zg1l~Z%{eahE15EaM~UodQF8n{+`7Zb68difHT#7y@B+5NWzc{XPY+3^fN};$vw`XM z1VNdLT&9COmVBI?8BUd?i;H60QQSpp)KnRo9FP)?G4AVcruLwG@ncWqeW*f)AqLdpeUGJ|34uG%NR;$jB~K#2fJRAQo3 z&9h0d|Ib?*8hQoh{XwJSKe93`B~$$^gz=Z|%%?(iRd=t{gfZr7#qS^ZaOGaKp_M7k z-v$r_z7M$8x283M8m^qyzrEs#TsMbxPT>owwo5|Y$s7xoYn$6D{|&zh#dKN}puCIRnzclO|l(?5UC zB&R5*{H#`QQc(VU%8{_NBOz%of&v25!gU#HnaWwuJ_%e}y1sGmHhp!$^qMGXZAF#G z*9-?eMa$s(kQ6_st<56r!h(43qM2*o-xxHLGu^n6WYN2DC`DxR=F;_UOE&)Et9?O4 z!(A`fk?Sced`?7$K1eV}P%kZ8Nkaen$#u1iPOE2u#Vo<4k)4G45VYHUWp;LU#U_nu z751}d(XhNh{fY#?QC{L5JHXxK=H|jvp-=eT@scAU2#N`FJ)EgNGku$!8lgVZD=8(z ztdc3qwaS7SGdsC#RoNukrr3Jpl?L1DiAH#^p_$(n$bI@jYRQQ=x zRi+k`BXa8&p)&Sp0Z1bav-R(|f5ss?=Rs4OAa8Mx2QfIr|gF@P>sv);k zmx_TNMJCzNM+a zSXs?rQu?9uk;0jQ%~wK6Zx7Y$cb3()KQJ(kTuq)#4_DlD$4*f2Ldui0Z{PNYtapE| zVUL$(t8Ysv(dAxqUE_H{EFFBF=2E}#T9+m{@2=gKoM*RCLi!fCcMn^YBGM{bBPUOv zJ^PH9!dUtxo5$ZiZ&UN79XlSw&TrQJ>I~&*h(=QuY@hKp2E>ypY0HB?y78HI244b? zkNd8Sl2=x+(~}1Jxh)p#r!j4k-Q^VGM#{}UJ!#PLB0T-NmNOgWXYG8>NDFTDm+MmH zHpAU!%9L>hD=Vp~)w031eV@|4EVDZEy=s~~s?9w6K8|_awSPRfe>r1;UDA_eSZI~; z^y$&I6vQDUa`j)Eu5J&0{&ML1^0ZI+aZaBe-gg86^X;PG5KWt=sHaaaxZA~#CT#n9 zJoi775d?CM)<#O*zCcwl$#YR+zu0S2?YwUlO@fFFIj1|juTk*au3g9HzKMwkmf5-ekWX8%JE-0og_LyGGtZXZuS#CNvf!XcCc6XN6Ai$N2bRa&jOTYNoWw^HWn* z)zyyQdtmVcsHe^abkkr;N zGy5d)+9Q$um)@T>h!F#XXp!HX>${iMYRZaA3VW(8Evw?`9T}5YKWi5-D=D0fk4m3@ zUpue zN7?S;);{E|@U9^?Dxm0d&D_w?%Y?djJ3>0SIO22~zO8ZfjSd}}t@~WA$HLWrj_w>+T(s2`>y=JLYFv;2geg-TpGa zGs%Tr<4evuE;Ji2E7yA8e9A&({ZI*59`ezua-u)2XZtF0|)A!+c#NdbL`Lq45 zjj2W6Ttu4|>SJ|WqJ(v2Cz2AVS4-CxsuYW8XsK1_a(R26BPCSSeEja+I)m@u<6iiO zr^_tgHXCWn-AmK`ZvBqWolB8!x;~Y%#sPfY79Ck44{opbd~;d)w20yJ92_m5!!GF* z-d>#|XSKD>IsD~PSG(F{9})GBFJDt{b`Mu9ijImhH_L`GhPi zX(}w5T6woi+uG{7yKhb!MI@xcqeTd7kWL-V-d6Zu6nXvYEMv86zg5I_;vmixa z$2sRe_K5hHnTUTr*+Dx6aX@VB2}o9v83EKn6f7OKTpSiv8)5ps0H+0N?B zz{HO6P><%n|Br}L!gQC!Crl7X0joja;l%X(gL-*a=tYq<@G9CON*y8vvan05gDo;g zy8(o1Yv~j%0{R$g&-@PPUTrHdx?vb)N=%ci_XG~AVU!YPUV3}GEUtNfHij+<_D3oj z;Ay5%q5}qty8scPq$G2&Rsj(56qU5-n$Le1P~?^svKiX4ij!0Kva|h*bmXn#Bag`P zJCSPj|BATC5qDNxRP%d%W{SVTIdOf_mwx&A5;vWve`$^@9cMk|G}}y~Y#8_ctJu;) z<5=c;ZP($Dwol*zfTZ+C(PZZ2*lTktDN~H77ltm0>FCJl>MpUsd*-mF3iU7D?wbM;vaRNqKAm7E zbzI!Snf*G}>bA^yp5%Nd*M){=B^C!AN5|lV&tFKNT}$yNZS+ZflD#l}+qg|iL&7Wi zV)32&#xuLQUB=(I7>rz(Hbdv-6PCYL|7D`AqwBcmZ91MvVPmx?K5jci63Pz?)g6y> zS&3Y!iWaiJ-0f358aevxbD?g=n_1^CHV#sce)KtfXV?>WqbUS-+!3#uivTkiAYXP+<)MJoWMuE`xDeGkG#C7+O!Is z4NFuFoaXGg)Mniun75_(#tI4PH@4hhEk0s3IKH}4Q1CfS=d-cX_)7VU_KU}lmvYZ* zxwx!447jvyuL)c+)|+AzjOw3g>s_;7DO0?N%vL)(KC5-tH7!HKlRDN*ql^CjN|L)~ zVk%`Ju2b-SS4nO`*GpM^dhg+1%fr7Y6sXMfeOPs@%$xF0I#vFVIeeuCNy;3bQPqP- zN{WwvbM?b!c+&99$xCs-5}8ablmz-fp;aVlzlbA1KD0N`1L;(07s3_>t|0>1wSc@J zV)}WM!W?RINDHZMh&Qx>;b0t$kCnS}rJ|+f7l3b`Bd2BEE=p{HKCBp249GFSVgv^$ zVd~wauSIQjH4Ufv!GYTV8P%}{7&(yDO%R5sy#0r08*Hfeh+!p*gd+9rNfGz#*rtS;)sc*1Ocfi0wV7q3l=Z)J=hf*X zlLJ8+d|wT{p4%UePdw6=sm*?rxo=p5M*YQs={wJwS{jU7%YwJxPfo55QFW_1QL4Q* z@t{^oOiE^EcBg4iGyO3pKGyXCF>w7>sRt8jwJ)8wE1J8r{eGKy9zWUB*!xn->Qi&8 z2|};Mzw_s$sm^95rR6NDD!RqpTlTvX`IRB%QpJ~d+50w^WL%j~)2w_!?C5WKxH$0T zOX-I1eOqpU;DNBqM0hjp=`$=_jZx(7}|W@ zaA0;~Mm%Xyh&NV?+sbr9(+JDHvDjqV7U4aD$nN2 zNER0xXD{T?_eq*~$56q~2Qpr{quoV$XSeHRy89MxKcjF-%p>!-lVV0QhmxCGmbZ-c zM3m&NqC+BWS}U=vE4}*-gu`ZMKMk#G`h{xQ6y;9v5t@k>(HPI$6npaDb~_zDD72T_ z<<)1ewW#Zq@Qu!%KKqb(*wnPIYmMo$u<)1aNet{lnA_wNU7bTAHhd5!Ds)t;IU zM3@&`-XsV{bD7?oXfqK+elE_dq;$M|*PE&Ph&=9lf+0{?`e`+IxZJDx^jGyrTVCeQ z{(-U5CPB2)2We?V&GrkJ0LDh}lla!^6+GC&9`K-mWdw`13e`G~zl4MY@3 zZmMCE!VqWJoZ8>>GuHroB%bBty$EC=9Sb~m#*6ZCPHeVMw4kZ@(2)#~Ck#ezTi{D? zJ_m&?lVSw`_e5|?JN`ijPhbQXb2Z=Iikf_e0zS)&mdE}qD+aRr(-4jZf?CzBj6oob z*Xcsq?V&~yWX#C~>MKcbma}Y7X4>a_lil5gL7F|K<9|wM(+ICfr5En6-clcUxqk3= zkJDtM7yq1e=oBs4H;tTE%Z}Zf=2}zJq;fb~?75C+GCU1 z#=`vEZv)F@EW1>~=SD2t>g#$| zx2wlVEiKiAxtZx)bQ@n_6_pK{{*}#0t}ajITbm$8abu|7xo5rT<@%Rg3Uan8GX)+- z3c0m&j?I&de)Ol89iE5xze#@J)6KXZx!J?xlx#%Iy_}pD&zzJBH;(J&tdf_qS(nVK z^Jao{5MMhXDj{K7<XJZDiyGmSW zs;^hWOo3;lY*qFl1EW4y5gEjD=$h|0DeRUL6BDdbot2^tlw@@^Cyu*i*+ueHMz1M; zTxwC(-N^y?C*G|uG%W1#SQ@12;3ff`z#jrB#6}Y?;m*C|aZOe2%(O#zSlIIDVoldS z#Rogy=}x>!_1eLBWPQJ?4h3=1vrn0)9zGTCo*Zn@C*$N~UvbnbUT@w1QDPsPiAj>Q zn{z4ihz6C2VFy> zNK5$quDzB0@gwOat7~*~pVh?8-J~Szc9T4Q^i-N+co&mZf|QcfX4a2YRnw1=Tf}^v z*)JkUcHqU^*fwnv4^>t7Xt`~hCZl#*@2Rf-*si9@`|ETtajstJHlJ>u_U-y6damOo zarO1D-Y(ov{gg^bv8JQdbpF_+!>b!l&zo;&>mqkPPve@B|J#2~ZgEx+AimJ)(q}VT z#>gZpQV!RR=;bRv`ukr+7eBf#8}2wYH#{t|Zlk!e=it7^S9il+(Gd~37<`W|_Jt3r zfS%*m0JUIr^yBz)Wd()No-}r5n(dlxRhcd2lB(_R`l;2<4~7;M9dM^P$x~p=u-U7% zj6eck^F6S(VCvt`ans1?lsPgjoKTtia%X2Q&eMbb)b?6kIO=86ocR3Nm~u9MAKydO zA$bH%Wp9^gesO{=mh@(CQP}3sa8Y6OE5Y!$nQM-;U`h?c1K=<{NJw(A?$PIXt3!9>Wq0 zRKq&Yw>GF4P25R;>M1AltUHb2?D_h&+e>HLMENvthxT)PzcM%8vsmkQbYNg$-MXv; zm;mvhlCuBlPrI|Sta}z1s;Ma{`xjyzs~7`9iN&?0^S+;|4b9-a<~y|8#rdA}6Pw>a ziFa9Sc9>J0qa(kpj5sG-v%I`YYPV~juC~M0JU6%F{`~`um~-Xq%(grc`vfviUH5G` zPk`YxXJ%ie`}q1j#1ui-4Gn|3{R4GY_$-8lSA({?2YHm;KVeD4A}y^~Chy|HtMgD+ z?3H0_$@vGW={gA!`c6-uJfYft-zy_)yLv~Tu7kJ))1_nOlqIWDS3EsO4{7Ga{T%<) zOI52U7gJVA?Olx;;$(XNQL5fD9g?*qD3r`&l9JS|ER-0xtO$|tu0SzfZk_;)>1x23 zPox@zi}R-O3e|%I0|`b60n(h(lWb{jrWb#eJXc{br9(M#*6+TXl$B}U9Xa%h{YQy+ zO|kqP@O4LlfJ1dvNBgvdd%R8-ifH&0JiR!Zcf-nhrjb2M#p0q^(ov`FpQK36b0{y` zzp&g{RsP1*B9?oC$8;XCL_)9mNDCuo;^?cMC%z- zDKl+TA0_hoNA2jZS(_LG{WlW}@0u%K9#Ci9g@1^>*7FP)kI(cjkG|H)`DPOFED{y6 z=^Ez~JrM_YCT6+M~Q0koW&x&iVeA`yl@&lL9KRfx$_pfxj~BM zJaf*0zDxHCbDYvyMOAM!WgU%kA8=NZkjS5`$|@?NN;%E0xLQG?`}tEsa%y{A43QJ( zJ)vU;I^K-K?a4=-c5QQHtqYolW3yLEJzM$w0@J4Vl~=TdL_}UHYbnjv&@RWzhBUtx zZMy8>zJUd_QPqwGQ0OiPJT|&}w_wlyg-~i6+qIcG3xY(Xq{eN|{hm(F^D-isN1aSkp$Kh|0~3zXfS)xMQLhED4KlF!=0b_Ojk$?W@idVHJHp|IGsF4a&iFH~VonBh2F)6DRIBDo%~zm-zjXJGx^>P2)SKkfn@@ zz;2hX`u1ft&x8B})(R7jp5c2`7*@BGlbds9=_}k$PBl;c92U2Zj(w~uS~7JI@l>*M z+V_5!O?AEnXSnIa;im=luQhLQ<#9?aMNbZge|)>p&lX%&M6<%a-XiTv96dx^b5Wiz z;c5fX%(QM>MVVS%^V^@tf2^$BTPTa3=oA$#UfJE*-F(MlGt@5%Dy|2Zr#}cmbs`(V zwVq!w_!(S9O-$V?t5(6M8>1l?7+Zg|PenUyKOPgUO-CiIkeHTM9wSKP;=<=U+%ZG_ zg*SY%qN<8fHRFp~K|i&MIRMK(kJp1|8rd$M0DEQk;-SDVBR%=q{CtWRXF z#{L|P7%_3*MzXNwermSU!Nrw_o)cHgzDyrXfy!%^)!n}DNm==9~(U${?_ zT$f&SZneAdI?I|-MMMZUTBl6d;3?T?@ z21*w~goec;2F4pA#LBCOhcH;p3A`peW98t{Ai_+?k^Zy(=#3jAb$m`-`K*ThFt-qK zEJN;O!jGjzL8aiXKTp9{^*)M_>`5JhPQk~g4qhSH25^&Mh%mMg#MtMv+;OVmiRpiXl}DhgM(^gu5MV!Oww#90VTa zzrgV4s~f9Uoef8rVMe^z;;SqxwYXZfx5Kjo+!5Ih)(4dve{~&}bn7}aX}Y=e$&*xT z>WOVzMY(d$D$4@8Bq1~8g%JN!^Fwm)sw*wRoiGOVulkzt$DFy%G<|HIjv zhhy2c-NTxrSxS;9vkVy`5>lCEo~JvrkfM}~6`GKFE(w`uk-1bdD`Vz_WJ;OJ{97m0 z{k+fb{l4wnzUz-?d+xfruJbz2fgJU;Q@o zDn^6VIrg;5Gqu)+7b=COKB@ba1i4wQXBMUpMRPleinzSGLdG#_t)>=qUHdULt_JPct^)k z^l7BkN>cn1c9ZXZl&%{uo_?^4k8i1%a-qDogPS|^%NJ80YwgUR6)i0VPoBJNt&g%T z9DH?%jjgKjnbR2RZR;c(OQW6~?mfD+!erYy=We`P7%w5+N{0Dvai!a7+Ff~!+RtDb zsDY7?*mPb@$mC!WgPwa(>2)7?~p2_pwBN3 zh_x{ooOBpwKdCOOk}di3DRuVK7^92bZ2LtcZ-_5OuYF6tog;ky*BpkTHuUh^7-c?Z zeNgmAtbA0?RrbA_!BdANJ=DN;Pw)x8F{vTIrGCqQZL;l#4M7jj(jUHG5~32_%blDJ zsr#Gqa%H$Fly3vSvMTdB98?{lS_Rn~GsekNAR`A07bIT&Q@uU$?)!B#H5Cv4f=eGP zKEdy`AMbwj^aR;{){)QFP#be%QjJ+%B!_rS#O{9h5JM@m+3x1DnDSe zX9LO0?Dgw~XJy{C<>yqf@n*{{T+rTlmU*%o)06g5QwNN?npWV3Dr$wBmzIqV48-Z@ zZ0cpJ76}#B3#Ros7;mfekK)(cn;t%(yv9C#fOjU2>3l@2?oV1_pN9%CJZI4=`|zsd zZdFm?&x75?*Ls@cVvF^v#*f4peV!|x%~yXs)0Xk(&6(NmSVIT1E!C8bF?=E-KeEQ< zWsf|_N{dTLZJ5tSHZw-MPr2qpnrh{;Qz1j1ta9-?zYjrdFEm9K7Z_=aDd#eD+ZuA0 z-y|sP-c3!r?|NQ}WhLcR0TMbI#s{5hvzTHh_i60O*RNmifBQy08uw9XaDId1awnGu z?OrElhwfCJHK-pHK3Kh};68M1JP35p`8HmL%TuKe$+}v3e5F4CBe{J4blzN3Q%=rG zNpceYU%noo=#FHXzX)ejH)_~=u!wfz|Mso?v2bTzRIlA&gi<&yT!}qsjWu#U-@&vMMKN57>Ghvn}k@l@*!vAZHooeLE#B zuJz&M0|S{0@V+ubXQXR$>a( z0&+u**)}+FG}`vF zI-Qf?yyjnQsyIIp;1c1`Q?FWXIk(W9wQxf@tuDq(m!R%Iytx4H1ladMkZ{$`?uaVO zz?BM!fu-w2>4gXHXEh2=>r@{g3U|=0`@uxt^R$Ka@$4t+WZ6b1Z_1~SA!_`+X`}@z{Iq~pw z>T17UOr$JCi-e?~`o}%5ZI2~?w5+BdbhLyzhpy-DIhI&o=70Z`+rP)2R>-A*UG%`o zb8ATcDKgxhVz`?7UaBL6qZ*ezv6Ohi1_9lj>SV_kh~1;+{zhpT34HrZr$I`%Tz24xtu zf;v8(41Z5fPR?Nuq067oh>PD+&&|tASB{fuY43N3RS68ETHtn}7%jf~QNo+(NjFo@ z3eRefX+I(K1`}JLT8C-mh{ij3q{GxZV1w%ZnT>`u-tPqBO}AjjbziNO(+>v8Y35{-#}o4`USq*fm{aN4GlcC z{Gg*3Dm^)xqEg1-B)MBIK-zL#gsg)_^ z3le5-0v`!ML8j4wr_|jSup_j7T=Ry({tp)L7QKk7!9$e(sw>aMcK34377)0~yl6j2 z2Ja}_GqJt02D&Ro59Ad~a4Y+=xA?uLEaBj;%*r2y^+O_ z4!+?W8tBL0R`Kqg+0w!+9+&@XSk-;Q4YOVHR~%kfhUpNJ5)#|7u?@2ipS=&SJwveJ z{oM#X5gsp_&f7O{wjLDLRAwv;6@I zF3XG8@*C7swS!nyzQD%BpB^J3ht`Qp6N9dg4ASkgL6Z*z2i*6`-aE81GHmk#ok3@A zX9YU4jDOf6x{!ga1X`==!e=YD;^^U(ZT8Z&xz+EMu?2ZX@!W8-|Bg6wdW(SA%gVlD zX}IsoFuol_SY2M2R3PohIm$|Jo%I0+T)!B^BRCsBJiO29_Ypf~+8U-abC~EcqJdcl z%&$5?%0CGkPn*(3b(3T;%%B&Ml{k)V>pXxOOYO_hiP^3q2i{sY#WJaTKax}vCtzw| zag0zPj@kNpE|^Knx(9_t+SM%c>-haIudaTzu3oTbjd@d z#R)JODoRV@uHl?k5`5j(HVf~wUtp{N(#*%)V?+{JWli`mqE1N|Wja4dtXRW0cL5-! z=oP9fOKZts#_Kd!>dnqcXx=K<4oE;z4NxG6?bv$a(w@-$bC)>5>9&3OqTmw3iNSl* zOyE<;MVP$Lz{GNmV4PM|HZk$j?3g^NsKxjyg1tUca585jA}UY}$YC-?Xej(ypS5X5 zuph-CbW%^Eoki~ss0=5tn4G?UdX;qp?zlllNQ@y=`;PcznHb;OY+>^b%Wi9jrOlm) zh=`T@^u%rDFePbta`jjvPb@`eSJx5MgiG)+cXf4j9>5MUZbK;a%KLBxag-gy3PJ}y zh!{8w?9t-X%3^q{>N+>s%2NS0@sNP@4B-?NpD#cv$;e;mG&=z`Pm@Y)&lfaZSubEo zGE^giC64aJ0Apm>ntekgtLU6qfFA5sHn|MavjjBF(W2s%RX0?x58NfQJCTfS>{|ewZ%3*MIEC ze}h~_`WAkD8bBd_n{HkG4yb222CC=;U&CmXa0G(f)TZc%1fne?tM2m)K>-1c@Su&m zGC5YchvxVtG4+9+m`3EUj{dZsPtcd`n=ospm2=Zta9nO7sqXDMBAVE7O0t? z0c?Un5Uwmh4Tuv0aN?*v2-yNXPQ+9PpqG9hCvg62?>sUdQbLSf0pFg*47qSy!BtDr zVQE8#~kQ4L^pgv;hSA(?(s3WK%-hxrWKp(ijawf-3 zai}aFt5{Ze28MdoPr~-QuT*iLB?@T5)bfSlDgi zvO(`Wo9x%ABrfB88*DDAgSm>5wx=q$3=BgM-q_iJdD`gULr6)?gsQlJ#8r8d`%q4z z_Vc9vM_cx?zpuY+ZeznW$P^!o9sLAd_dvxqxhr$Aw!xDzrKfFUaAFJ2OF*E!M7Y(c4LS~ zC_@sk&<8`G7dPSI|E0JIhF6$9h zjst+nKz8vLkBki|}QU=~;j+S}vBKfCeFjhlhT!cuWHtsVV` zVNLI0+7fkKgj*(I@Wvz2b(N<9wgM`@-yyLB-}d|Ugk&|!to|q2P8E-$K^O&L(#gFC z52~g5uq~zg5)xQ`*AjS}@Ppr(CanqkGli(`QrBgS$T5xmfr&bCm&L@~R-)I+=RNi~ zB!TbL4?Qc=XFN-%yYl_0%{<+{-^-R<-b`A=1hch(GW)YM4}vbIw^x)kXm^AMhGsav z7G_mVYwmn*D9_5k%skdpbexmF{MlXS#_cu-F(h#T`n!cC>OXe5{QLPCfmDqSk3ygl^~wMSIEIIS;mN zsxt(u63)|8$+%&xN>9(+=ZA>_A|a9NA;cqL-?;t2>ZwdztJ_w3x`lMZxT=z4KbTp6 z83;Z%AAb4b<5x>cpQelLwHdWB^VigCX}C5#9G2Iyr+${D1|`VzGCdIw(-;&ZEAB>@LrP*wl-g1=eJG$bjxrS^ti_}^A+;BQot*V0B}d13tlQOkm-tLN zqpznzJ#|`_TgbMbGr6)X-=e<5N^k!U7CAD#IfaZY&J^1&Y;hC8OP|CRn{LCoco~x? zUYC^-_2Gm+M7U_}3~LaLeqG%L3t9?_D{u>j2?P>p)>!ggyDsA(ht05hg5s%_hvGJ9 zl=S`K`x63`=NycB1J@ied+4XOMRtTt4L` zx9ub9!j0H6zAp2y$dk=Rc5o#2ZGFAMw|Ye2)g68P;(GVxJ+#!$jX$U4drHYa_gURP zKDD2}wk{Kok}4KOvlCT ze@KEj1|B;yiO2h2?7q!pVIuHQdv9weY5*4#lI6+j7sLZ3g-mj2qhXdG6hhnzdh z31bz)cub~(cF>9tHBH4}cZ1UP`+4;24E^9AnIN6 z1)MR5!I4AwZGG6tKH%Z zlE&V;EI>rQ_~(I>L%@lVV?b+s=uUv$i7Q`ybBB7Cm-3422~C|ok3|DR;xarHRm*V_ zNw?RBa?_t(gavD?3k*2s!jZ(tM;_0_nU56c+sDipirkA`;WzH!v_`VyegFP`Q5Po+ zOB<*$8S3kcvAIO#1>M(Af6MJGhDAn&OSkjSE{YS}MWXP%m)2%pGFJ3PIsrFQ&F(D@ z)%`38n8&Q>1l08j^Ee#k7)}_EKf^~-^P0*5;aCZePd*Wg#V#N$%%MZ0N2~BtUtwN) zl*o6-N2c9uCpzH_kBzTAi{dVeu&qSB67=oFu356vLnX;cdWADc`~XUwoMmHUVKE0W z8Z;-TbZi;lVhm~RaTt8ms_&SJlmUs0?;TEug8}a{u*WR6;Y{VfF=Y-c5XN!v;bixM53kBV5uf15?J9 zz+47CTn6Cb|8mHHoR*d=zW?i2mR*mkLPHE(+RDpK;kJuGkR|7f4@hw)VH!;PFbZ)- z$|Be0lHg&Y>d3ED``FQ8IT%oOcNaZuf1BXmobPDM3xPyZ^Sj@rw7B3H>`KkcFM7nHj@NuCFeQY(US#w$}bkFk$`;JnF%=PXkT53|-VzbY94(D%JzrKQ! z%JUe^yg!inZ@91~S`#iOPTmuRK5y| zU{9P$TKpFN3$tq&X#$;8dpZ<24fI)lGF%m7nT=Y1=Y;(z^eFEy^gq|`MQ0<+PVhA> zS%y2{l+N?^V9q=-jV6AByZ>z&Ax-4!aI^^z7oj>Fr~Ui_681=LBhNFQo(`#JA^;(= zYUd;bjx;B?0+<8L`P<--jBafi^PKk_|D)6|d(~B3g`%F?PUZBd<=z;I3xUjksm63- z`o7Z$yd5$4T0Kr?&)B>VR9h&geRpi55!DMeP%zwIR%DLDZ7$1Qll%jw=4fo$Jdi2x zpGwsFLr;F!`s>|M>(|Y^l%_TlUn}S=l<cUVV8KS8trg<528R8-X84efTxPhriR zwtR_;ZOaM}%#9SfA>_fHBcRn$?Aw=+dcgykBXw>%GV+FPTcWc%&@Kv0d#590%`i8I zUN_84I0T;xb9#Ds05(ZSlmb-1#v3kRbJ5H!5AhVcRx{bJ=zV$lGq{0zGShQ+!#HT0 zK|)smes0`#x#wV30ObjPccCHnUgt;rT-lsNd1IDWk8?D_VrdGnn>~ZF*w#cxrk)qm zEVyFvnxA|x0rwr2gI;iTTbvSKNf7B60ZQI$Q*M$P-V3wEHcfN$gr?&{Li_oBb>)NG zpN--eIb2Q7C==UQ4muJ1Bt#dd8c|UaodCd!aQ#C?mn7$(kb-Li2Wb-FkkLPIL5?Sv z2<@Whh7b~hE_QyPc-?2&zc(&{RV}Hh(T7os7?y#AAJljW>k4%Qt6m{C-W*3x(zNq& zEhwfX6=<#)jEf37TGP>?WAb>0%{ti9+TsOm>TZ2tZfWsxI44+Vf(_Sypa7ec zu>SyvQ1kd?Aykx@B=WmQ6K55gQxNomS|I)VUH|9!ANq zO~T_|2Vfdjip&{2H)AXEuA#gt`J(~?0E>#MMEp(Q0_y;X89z3-8BxV}c%Z%G@>u(^ z*D_;c?@LWquI#1npRApD#>N4J?i zf;o;<<8vXPNMLsu2+}alH?H)DN%HF(9P43bROdTK6OU$H;FxqmGAZKTh*PoL?BTNK}-mt`QB}A_QTa zg2fNtR2%r05a=%auGX6ZYe;_LA@9VcPj2o$G6H)OMCc-U&3w*Rj|sJteqHy!!4Xvyo3b)9 z{iZG>14}QS3BR%3|5OzfOz7U$)~15>!}Wg`p#8z2G6NYHApPXY6NIxpoVuDk8GIc@3lr6l zrJ>5@RC?-Obsl}qQb0Rc%V10>E0lzu!hlcMtu>Y#1XUYy3(*E0E&IT%bC6Z;s`NxPABgv?fhN@ z9mKc6L89fuy5qocMIuz9h<}7|u$2=5)a}+Nnyawug})$;h|~3K(^_SL>lo-W=a|tz zoMMP-Bu|KKNb+42ornMw4iiC)vcZtSB2|1{Q^P(@bUT54axb3zjZCZrW)cdL^+apz zov??3moxXJ~--K5GQtX}TntF0nfPi|4=>vQvjz81*wr}75yS<2iSmEf!Kz9Ib zP5|s)sNrzmmm#I1vM9nVbc-URciUIaE4-7$t?17GGt|&$w&=6yeDdmI^4LfSYp<_w zD4)&1MJ;RnQrp_9G-W9n8T}%AS2?Rl=Uov31p;d2-#BRg{p5v*Kr2)R7b(-`c&|G5 zwI3wk2}=eG9hxyR%Qd~DQTFWm**$OG^xxs?Q59qo6l}vpt*R;_u(KtjYTr#xj>!Jc zh4AdxmHH)40BUN>%L!Cu^+mUX*I#gRw#Ryxt4YDf7BZh_%5;ssls1sw6!-nya_+VZ zs)}tF(8EI>0A30F`v`d^(A6f1L3E6=rV(+ou40OgCs!Eai|Rq)>xw$fd9%Qki6PAC z1iXlqi4t6uiAK=&O_y7wT6itd`$7>`aR^)R81y&)iSM`--9PBQY+`LuGCE?mHP(T7 z>cb|3RDbctf`c~G7KygykgtWHFNq;?l{4a}PctTL*}ORc9gOmyIQ6`_N*5*`UF$|7 z+y=Kqlqn37grU8vX@w=@_r|(GKy?7-F?HBy$4R%1Bh|+q9WSb zrH}d0)?Rt#zm-~Bc2-32*Yogum9Au@_oQP)3=ZzvGooB|xm?m&%||QWb^u^4qGD2N z`MY<~pn~qRNrH!TkpMPyD08%}-C61R23cGXAl` zP($1{4Wge^jYwr;BL=5Ic3A}lh1XR`F$r+?_p4tE5QoV{(>c@hFLP>xEM ztaG^HXA&*mg0S)A&vp5<-pnYfn&N4p+M}!zHNE)Vc0vXJ@l7 zE-Y+4LU<_RBg@$*sD4J4_6D|^_oQH)AsfPlllYd+si~w0xMFKCqR+y)kdIB}b0tf7 z4|~@$+mQTx2jKQF*?F?uKop=DWvIv{g<8Pc$_hEgE^6xJ<_h|0EN`q6mINy+vdRgx zcrb4aS4med3o$w=sh>F382(=D=BoqlVVJ%-Q|FdTTd(Q3*wWQ=?a|Cd9m#>G7;uBc zxPIN$gP0wL7|q3%j-w@Ul&St5u(FmaFc5+VP_^gO-2zZfkYWnjHW5SI(a*uy)e)jE zqN|Ihd$s;2-+0(^d)Qsd*s?wMuD3Tv8L9)_$I&3x&9sAK;;W&B#d#YWyorQt>)NuE zuk8ghpO4Km(nUVEk{k>^W@iVn>KBHzdD(Y$b>D!o7NPgPeSWrdSMX- z$Op6z8m-)t^R_=epPhXe5)yLb!$J3C_W6G%uEa&WmE1#un~1|0{TZCc{7~>Xh`~29 z(*MpKN!(8Sn2L<(z9`|>PA6GM>L1k~x&8+WIO>zj*75e8Wa8k?-Mj6^^))Yj*K<&0 zMQ@DXf*bchfA_w9*GF17X2avI43WQmaN^|P0r$Oo`Pm9R7*DpUKoVPVJ5nxtIqXn2 z(1=*CXPeHixwrAygdXa=h$`90E4XeqSVpL$vVg69kk|X39#MAo6f{ryg%%7&z-P+* z49EyC3}_M;KYaMGoV+{+VXNgN+!lUG+=g53gzK;M6V{X6!nz1dExujdSZf_+H zS_d!TM24pYrsH;uOWEB3>0|B^qvD@~c1o_i!fpq_xlR_ebLmEMmV& z1H`-C*1G?Zf3E!1?;vH!^zQ|H()y1}{m=HqN-Kx>kpK6Ol-n~A3iSR{M2Jn#i=EWF z!_H;D+abO-Eoz%9#azzDLpNIupB_ve-MM>@%Se0Jo#<22()Wh=WM$+wPo1Ep3F|%; zbNFCYT7zKb)`Ot1^c}sjaAkHi36AxKKtCrG!{Pw%K@ig+?F3Ue2IKC3lF6Gfp`*Xk}VDh4^ zpdsa+JCz*mIrQX(<9xw*+i6ehFU~Dy<2I8xQC~Rsm0jEIC?QgkZlE{Oyc5|G2AMfc zcSaYNaw%$M4aFx~?sbY)7e<#U!{B*pbw~wa#vLF5W zg^^X&=3}(=L%;2Bt2(#L?07Nrv-lJB9RHmq&Am<|_Zz17PaWi|tjl~8_BtXjHGJ2e zAD0FT1k<-Jg;)t?m`LNGqo@i@Zb)hTr*g{f%7>PxJA z=gu#C{4*^)D!$KqSU(8Y(G0Sm45WR{Keyzu*!^held3iu`I^Tka`iq7NRvHx``+$B zpA|T2{r%Sl|GZ}NChmzJixf1wdBvCg&n=c)lIlL+$R(R}?fXO8DC4jt%E)SsMuFgU z(=PLtu*}h!|NLNi@mYhsUqa7~xtBKDy;!SLM)zB6+EjY_j=p{;J;(E1oyDC}{=HsLDrvFfR~7=OrYEN5+~c~~BEobSnBL6m35#6qmrdJasg~AU zbS_)Nist@EM`tNDi>$@WNYRdE9f$GC7mt2qet1aR$ziC$Cp3|HgXgt@1L;`K#sRk0 zT5+)i+U-*nK0(YgY0Xu06qEYzx4m|IpQ%ZOMfOQCB}X0pe9=AoZwx+T-?Ar{H}bNg zruH6)2pITi7G8;ONfyr*{l=Lb*C{Oe%l^)D???Y}$se$GSPaLf<_dW=>KEzwnJ^c7 zwxzkW->h|R+<=NN@#2Gn4RriMhebFghCEw}h8%^a4<7W`Fmh*+&#Tso4D!FmLA&;^ zPgoybUH0isN^DaRl|n~yz`Z$O3;WE%yDT=u{k=(>v}T82Y`Vq5g=vrQx`$OcpmTAGgMXtmBwfqri0a*a6vL z$8F_jPPVj4o)x+AC3^Sn&&uQS5#t%s^J{|D`B`&=rxQ|n(22d>!prmd^2f$@B6d- zylmShv%LF*^qjd`g}OssaTSDEt@@$~m=h*zzGy6ju$ z!a(8AZyRQZEal{uC%>6D&fgQN)mJJQ+KB${nF)SYW@hG|lFKf&3#8ShG>OlDB($6f zCO?uk->Xu&x&Gi{-#ERF@`36SbLrGeLrst0ym5aJZ*L&#%5XNKo$G4jM)s~8r=+En zgPn!Nc4Lw0)t_INuv^<-QYtiRE29(PKBOMbtu?8%{g^N4-ZqK&jcoI3eh z@d7_>v<^5;PSlWgHQAZB_j;FZx890AeDs(4Yza(==9{dWFdYvOT#Yf>)9}E?!sYRVudV`qHHmr99tl zw8`5*F<04|_ja!w&4a=w^CqR9LT-Aw+L}AdBO%)Ih5OZo_Fm&O$Ul`!`=3ua|N4)o z3-79{WsA*sl&J*^?*G7k_2;dHgQ72zgSvz-XlZK4x3P2b3;p^M_r~3wXP_v>y5Ysr z{Izu{DO+SeUmvI`i7Ij6A+fmKlJ2l0Jl4i{CoT1rNo8AQ+xr&F%1G%0jozL)OTnem zU6&bKmGmMsVgBb@`q#7+nRaYL%G{CB+1icTsm)AcxtQ8PlI7K#d zHmqR>((tqM)6WLO?)|oZsbF6#-e;!r<`s6%zbV!4e$L95Z6I4i06(iQMYm9tP9#Ni z1kITC_dn=AOpmYwc=m7DBl*t0mHT(bhPPADttUXB)fZ80AvgT}qM!psVXj7pvHA!7 z9Lt|SB_UBTV^|ZNZt$dR3!F3lgCT(Qlp7gQ<;1^n&O32!)@u}K7_;p9>-%|+1NOim z;1~rUAbFISj@CAbnklMn0u=Fv3G~U(T+O$WoMCE>BRg8zI=}=y6Y--~Ubg$}cJAk} zu^ET+HN2(tPDY`33oe~8GJbrf`s^7O;lZs?BWj~>lBgVq6kgd1P%OdE01jgYtwglA z2grvn3JQ{HUO)_iZ?$Yn=>7D`*M;CX!o5I`3}uaaJqH8BnHlw1DL-6Na_1fznqrKI zpA9(tvld=JnCbwc;9T`GpxXgY*=X|*Phr(-djmK&TL9>dwotu-11RX8m}qg2oH6{X zR9l7?;9T$}BYb_|$4Kt@^EAcI9i}@ob6r;Q&>i#@xUd>wrolSQcuO1YOm_uxuCRy* z;K!5DN&8)UgS|802Ev^5#-aZH*ol0gGQVbLfi)$9(T+|(fTS%ucC>DC_fMC-K=3T# zm;PALPNJa@wS$jZ=MVW?uw#PJyH3rA*oi-*QfZ|^`mt`CTIxzKPhW&K1S((Fh2*x z7wNgf(q8?V?HisA;Rd7VpP8D<$;nZ5a9eK+$&&xpxW+HIuV5cGlvh>B3@}J9F+5@e zkMKN(=`{d0W28L5YwCp|Dov{C`it51A^}wjqKm2nBo4blhj0$6hp@hymgsz6{rdit zIv!#4o~_as z{9R*93V^geq!u_QYON2_0;37H93!ZBj(8&mYRZN44Z*}s8O|L<_XYPdd24lr;5Zt2 z03iu(=WufRR)a#I{pieGL#xd{xpEkd4q%ro%=!Pkx`l3ujJyZg9%B^{8n+oRk|tx2 zobIFbQCV#FzJLx+Fh_06aSa1^F-Jj8i7W5i@IV^6zYTA-(enwYe^|t@XwK`5{$NO8 z`|H=Idv+^{@MCaGvF&*5A1pvy>*@2R(_TyHc1(0PryDFoGepUypOcZ{t6+NHPjDb0 z1K`N)FX<%?jz-}rga}5qE!(#T7qZ<<(A6`Qlyhd1PGrmr@Y3b65DYNmzt~-r->FbJ zgWHzA=vT9O)`~i<%CQLuCmKqX-txP4Vy1XS1Z?)G{uZ%fK0N+z?;B4>1l*<+FAD{& zTlPG-ndGg{FLr5{m4ciheJ=P1pZJ7e<^H$~AhE#lKsl5tl@_OWo`q+F6*{=_@$o<( z-pB$(47noTK4QdsFC?cGDDQW)MTS9|e-qr!^g2V5?YSwCA`!Thg~#WBJ4nw zNcsi?mH%ie^drjY_4H>A4WEt<4}=)FpasrQO(>rkGXofy9+h~q8uv#s>K)N9dhzBa z9ufcO7-1}PU4PM&A7=YEP$Xp_<~>i8drwu2wD5D_&) zcKfbpIqMUu*T4Je($6JJDCq(@9e7fjJQIsB={6YJh9t}a&;_`L?%VK@fdKjsk6ey{ zr0^jm_M*RXwN@o-WxVXnB&L{FGs|1j*-U|52ljwIdVKkg6Q*%KHyK%@FzLt2DkUc~ zJNty=OiI6xa=?u*ZwH$YJ;qDszhU-C$i8%NhU+^^jUCB%R?QkOJT$)0K6=~YpzOC9 zL~0O>pbAJ?-GjgtsJ3#4-$BrpVPNtAG`)Vq2I*S^pFU;awt>HF;>1i)mB}W=Nf$6_ zHo%m(@-;(dW4g>yrpxWv0g_((qNgt;rPF&AgSpI;okK7Dvm_8n; zpiv(4c`Jxneg~u;R5G>(ikeTf`93@)#Ko$f5;`~7Z5p<|*jDyAou~&sqIv>2_21OM zEz&5-*9>}mi6ayo+Nw`kt%px($&}M`M^vc;67YTT6fIk2C=@)a)X6u@&d;}|XsO6< z3gUc94fO}bim*7d_sJ8mG!zT8_Nm#AG{!$ zA@r4z6buj4!s6p@lLr+q&U8>kSt%%Vf5rh|F{p*U0oaRGW@4pK6)a{cNM}8NnGRl} zKND^j_7B5-pb>E+R;|!TyGnW@gTP;CX9NowPpK_AK~{wmq>DhvU#Or_TM5_7m&Bt$Upu zkElay&dt**8R!EGPeT|6&6y3D;E~;kduV|*iNvM%$dN8bjaaE& zi%Fw07w@{Lm6Z}wmhIQknBd%Wtn)UXZ_3;qS{L6D@GP}k&h>>c+z9birnYG5?sfqL zO~?&0&fj(ter$h+gR|E5A6M))oZemFIg36pBWt&=)K2+RABb%Pdhm6GGGRj0ndy9z zktP)_ZF-i1m6a7%hLf{1ZWNc+T_VylpeHWm^y?BLWtBQ$?%}A}?^8J$Djl)Rg7U-M z4B5pav0t9I>Ib-e$5<+Yi@+G}&rZw^OQhFK-EDTi8DTup)n69$Q*eW0yGvWDk1|Wi zg_xnSi_%kY96^$k^3 zD^c&`pKRm25Nd1|>71i+GSVD4b|J8Ls|Swh05?Sw0qeS4iqkw7Xkv5Zx!%AGmi^{?Jp3N85VhF!j6(~w z?k9f9QLF3@OnVT)j^u#HvV}e9NPkyeW~PHSr2=VhKRFU7BpfuPZYPC>IrWPYl+R{U z9g6-3AtYWdEDPO+d+!Kml_#*6dIdgHt!T5kX<{5MX9|vzwauq4i{@EhucgEpcXFj=j>15rBp;1w=T7x|o zx&CxsE9`4dg+`g5SGeCaY|+?dVRYzO8~t~5C9?vLR%R$y2Pii(xm4RKi2Nb%D1VmL zy71cZi@Vw5>^ByKVz7pglOv-F=Cwe?A2ww@r=!zcz8!MnfS5>7ss;{MG7WSo#D~N) zC?GZ`F;gbgo(lEQzlJD<(eX4q#!}+qWk{#8^L{2phQ=DOuI;06mYxQ**{?$Mcu`ld zb^VHDD?}RF8=xTKw{r?-_be1ojHL&-4bKRTJulRTygG==wwRnjM@vhZA*!G-_E4(X zg&EJRKgV0Fm{@3()M2$0P40WrpocyW$2A{3eu=KOT)X_q*~_fv>xB1j^d_{f+CN_* zfQeX(QuTiOrjKIgHzVkXfYnF3YOvrEBmMmBS_-zxFi{*l*p1Yi&(Vw+IX?I5AR|o? zX8nM=!th+VV4)ejD$ZeS5&0i%uLWtcLpr6UPd*>o(a3D`6%+12X!7?cxwwDFjvWrS z(JVn?6qBWf9C#o*J}O8tO{%UX<7TJbYY`9IWE^6g8z9zbazqbe2q_{etk^7>rPv}6 zO8hA#Bqe)L(nS<<9}`Rl%ghd1!U?ks5VcryH?Cjr6diE5RHNYBP5Ryh;6OM;$qr>|#Ep|##kp7Wf3nB69e2PY?%q)0E@^+%O zX5B9HW5{KCK!%(jFAWL5*6ko!I*qnn;G3^84@Izk0uLy0$}1n1PBM-nuS+~Y*LwJx zf^Ah3y)_d58Avv<41Eubpo1EGMm49tx+E`R_sF!7g^bt&( z90ShCchk%sPaLHCNyJHVv8tQkq;Q%g^iWV!9}&#dO=k%Ar$Yk0bw8h!=-C8)47lt( z5TqW}1@>Y!6+{|Z^BF@Zo6i*{dotWXS2n{+nS{(Mx-Y(ZVnn*{cmKy%hv@JK6y>q) zV=xL}`Nl3x{1O_E3UOFd?GKa?xsNMyT9g*_KnxZn%79(`Vgq)RUAIEFBnCus{ zjWs?9^B<{VT2c!_GY-x)2z64yF;|?cr`Gi1DQ~5=fw;DxrM=FhUkuk5@`A`WH42s< zfTSCwD^I=t7;~&$ohQR=K+Tu95d9nNR(HvtKq7oj^h>O+IQ*)obOuVS5x64WDn?W& z4tXAQuG8eC!H5)FMd?-{@YxLm+;t|n-*0@l2hB!uYY{U^K8R%Ux^GybnTQi3K;ePA z196b(fx&WoyUtq5}dx>Ej&ZKK70MysS4P&s2N93sUGiMUyJAwgQ~_c$Wx< zZOwC*rh;^VA=OtcOi~@A4pe8{WN-yH*mnzidLz2bd?$FGou*cp&hk0$jJU4?!3@1j zHyDBuO=}qb#FMs)GZ7Bw*6x{pdUCUd))(_+vhAc8RIwJJ1vK%7%Ja2Cu^Wt zZkt-DeEs^1$oxqAgO#EpaJz%fKvq56P)LFsTZmXCA6cn%FaZo&Fp8Y($DRYpA-3~V z=q^JdBQ%xlzz4>&uNsQmzT8iLD6h4%Q)j_wN9RVMjab5!p zYH<8i=x&8mAdM05RTwxBFdRrUxR3QUPZYTQV$BU51-jt^ZN5v9ZZ7R#xN^KXc{{ShWB?Y)*>fA!gvAib&NjF7U2Zxid!q4F-;5$1p|* zkm)(?{z>DRSO1AhdhR0WlD41l3t$!-9ho>Jouu z7}_|v=Kcjn-EQ5)33(qDDET8qVx!)*ZQsV5&;ncF%mc4g-+8(jd@?@E7Av4jpjfib zK*%fX(2G4t9A#rC{t(u*Z^PUISX&NqdWc7oA!WhFc;`*c%;m@O;gTpQjkHLMP=z|; zC*Z(s3wmS0>T@`lUF&c%rXIFNO=7OAs;7yuC?PE~xeAz(RsX~v(N9h@lYDrEbsGdN z2$T>9Ddt`5f@3;{3ddd#Vj!#p1FHde+9BnPBOstZKq-kL$-c^+F%n|8gzhEA{wEq! z3AUaAtR_Vn*G*z#gPmQW-Js8UQ~xyS`Sg-GwrLT-F2zt(LyrwhD-k}vDV(h2LCCh0 z__rMt{)Jk|e-^(-c7_)dA&(qMrr_#9ffaij&Z8g{CBx7LWgiZk4jkmDKQWwI`Zh?j z7K07|fkE*}Mt+N(+-`}adF6-u?Cu3S*91a zzo|p^6+UvZzAt09~z#E>NQ5n2^gI7e7yah{rM94%*%7FrLpX< zqIK`~y8^hlZD02pwMpO=#k8Lo-r9U2mMF@>X#STN;`V_T(kti1wF4j<0b4i<4in%# z3K*jJ6=Yg9#NQTI<{KNp(+D0wJvN=M4d@d>Ad6q^0E0Gc+-N`CBrUt?7_@7bFqr${ zU|2OC7h^S|`;a9AXOIa>Zj~IkJtUN{|A=mQn+62d1M!wkzv$PA@uC>H|lC46Y1 z^L#Fhc__URm0bR0F%R|`@2ruET_R`W#fov7S2qBh&1k?yUsBt$Wyer#_icAO zko=YMtN*G-0(5PVZUEDvw=h9XrNS4m#jnHvsl>VCpv2q0!n2U^E-hU~{-sQdv6g?A zD>iIs6l^z?8(x9;PgSRQ+`65Qw5Gwz#?&;})EO|N@hsfuUU%C<8^UJ=15pbtmmhB_ z5j9jC%87!U4oZ-R&@_$cMiV z5?ep)&_2(f!L%k6$UGQj4GemkBCM>fo0sjUPhcQ#W5JGRF_0x>>B8|6bDGt&QB`0U zQ|0;{IG2@!WeR~q-1Yq4&O7_9*&F%l1yy*mqM#Q96YL_K+Lc|9EH_$Bg0nhQjq2tO zJ6akssWMB$IG@JHmsf-wYQrN~x$(1e8>;@}!nSe`4}DAWIvXS5jaA0UtqFMo0j(TE zpo4dXFNKi(=r4rQ(&eRjTGxfl^?LAv(E|V0{X)CbO)3p=&jD90LBvLY&4_52?geNB zStZt(i8D2dK%9-+!D9cM18WEfG1NX+0(QoXcPOGvVcX+*0@{^m_P9md$Yix-rooog z(koG}s=|e#3%;>gx>?D|$+}X*k1CQ&a2>yX{lfM|(l}H&UUCGrh3>7ASHPWqVLGkL z)CWnFF#hzIZ6icS_X!{bb`i$&hWE#jsUm!m)_Lj5JUUu3j$bB zk-PzGz8)5f%4ltMwIu+JBkCzHF7@6rc2w@`?e+6;J4!$A#~b+M3f8oL^t1fkn>O8n z<&Y!#fhb)9)u7?VtyYS#SEB!EjYE+z4bY}ZZK$o~n7;aVQSYPOZGBjX)D&V0I$e{n zm{hjo;4%UnV-qC!)v7Z?aA-u`&OqP9bgD@fvmHoD;jZEHCU`>7uSmYw9K7$WsHh$! zp|XK2ekftWe=98}ki7S6Lr7$K{=DB87dSm~wGt5xu!@F8-;g^QNrfaq@~ci~_Eymf z`u~!D#~{z)wFzQ>_wZ{B8OgmufC*2tFiP_ysOeyP`yHmC07F0SifT8SbvMyE$-B_Z zc2y|!R(!nK+_w9}e_PFNx86vt6-Nr?Q4k0|yNg-3CQ zL;nGCcfbTr1f+|zJuiBTf2WPUg_tej2I4DJNgvWNW)V7~Vr)4!9Spcw$)n5b>du+e zpvOu@L&Fgdbn!dXaR_1)qKK)Y*lLWArV6XKzqj8I;qho&oED4ijQfwRC zNq4KxSiskaw*yLBjlWO+)6mM%sJ}1DA=y|k*7a{=pGc1X_cZhWPMTL-b5?)D|Bs{@ z(d@VAL+qO&@lkG+<&9lxgqhdsXZl+Hdwl<2yiU12z9Hz?K;6K=$HzzS4fyAXH|&9l z#}x2iiSwFXJhuVKL(3gdSOcQRsSxQ`j@=bdjw%?k{(glLmz)2YYOjfoFHBxwiTU(@ zCFg(dAf1iTcJY1=o1wq!ill$*7rL4dfbLhIn1P7iiw_5EiS5As(o)x9c`)Ch%ZBVk z#&rjjY_L52iGC+^JZc9oLC5z$)EG6qpn?9IkU>N9{^q((Q^}q&Ug#8eR#r+MQGj^> zvWc5Fm%~yL(5Xka*Qnf!TVOdV&vw=QjUW;{pkVz!?7d}Nm0!07Y9knggf!BPG$_*2 zN`thZw4@*%5)u;9($d`>f&wBT-K}(Y=bc;s>ieGao^#Ka`|a*8{#4le+0V1qTyu^w z=9rs9`te}wq(<8sMpp(@ELbl|a8pw10l^P}`mMiz7{}S3%b5-Q1<}wmR@nzwdTl&x zAn9n174UHmMsG0!bSeU46fe+GP0vcXj_Io z?HjQM4QD`4V|;4ZAN$HqRArg<}8L+{vt;4JAt>c|xCSE8H=LR#<+(#?M0$ z1+nRdmIH7xpFVv8)&%-lQlZX=i-~#Hi0m>+ayR_<3^(*u`FMcZCAW45Wd5+RXmOg+bqxWQc@jApbN*bDjUxgLcz606 z4fu;do&(U5X5l*^$Jh0#V>nErE_oQ1a_(W07zuD+7U|4kT8J1(@TeEkJ1Ai`iptJo~bHass#{E8Kr+K65QffS|-_wG6! zGU8r@;V(Fjv@7QrAXqa09(Ta1WJ|)m;(UyaebsWS7W5D z%>;Hj4GVWzCVu>({?A+z;RS}s#h}0jCmdD(nB!kfGQR@(ITm;Bt6MR!1)(wR1ZXs4 zcmoJ|DSF_z3Zjr;LHIo=SLVQY4q@WK!OD6cxGmTS%wkZtVg~}vP zDJLpM4H7&sKnwl6B`zTm1*MNtc$0iMymOD6IGBUMBcdBycDKZX3qnj#J8i@_Yxwd7 zAx#NK08YPNk=meGgR`cPfG;ty<&69sSfmUA5D54@w1cGl8`}J;J6T*+4;%s5ET_ff z<$G{h_0pvdC*LxW&xH^?*5ROW+#S+KWSy=uEr%SCI)X_;8zMI?h^dj=tc9IBhnW+W1$u(b1X{@g&lm7)!8<#GLL9r!J^#p;iLTe=eJ7>V$Lr?D5ZNQho3*|3* z#qv!!+WYYZqF-3p zD3k{GuE5X;C@IB)ZlVD4Eq9OB%pB2*diyfuOI_c$j-!4T%h20)xfeonc6PQ=e|BVO zsMSnM7-)~aG&Ul<9RIBGPw9xn3QSUXd3g^N!B7A)MSz;Lbh7|Bh5C7WWN>KcZ&^8w z4c#(E(3lG4)W*&Zbo(%<6eh!#_4M>?F$H`Rj&g}Z6Olk7jVvVw05M-rBl1zN?X}+^ z9jIl0<{DTm(9VNuXJm^qFY#i#_;X1LZOV%ls~Ca6E32nglWPEf7Q5Y zFwXn_{X-!wFYh>DEdaDZHbv_NRcc=p&CE72v{Yo_-+k(;R1W1b zf~(>LvF+qJa(JFSZ%Ja zcXAdL7TyCBFL-=AM}L-DlPvUKAy(jl>_H5<6v9>$P!~9WzoE24>*iw}CVQsvJ&28YKqKhQXw+0cx5`0kESCio5m90*KLnL(GJpiI~jFi+2E_WJMk zg*X-?w6TgS{UtAk(ZrsJXdS!a>O^nbrpI!MCw53W#>b&PgB&v@!6CWX8J=$JKDwKw{ zQ&6Ey&s#>o>=bHtli}i^CKCvnpm~h~+rgHH2mtt0{4OrAyaF#4gPu~*K{^+7-)!(p zC&+RwCwjN8A^bi~2jGDwpA-dLKX;cSZZ)^i5iR%ELvmGrMlR%s_ZnDzR$wt8U+A%U z6Ec`#vqtq11&D)?jT6|RYT@9H(3@rtC%($kQ!0V);I=f=u+0uC2M%`jmF|?cHI&v9 z4Zt`wZ-P{uR*eu^%~^VXWvn7~FsiAH?{r^ihEY`XE2wBNY2>D18GyZ4Ge~*Ke_TUB z>EJkL3KE64>??H z0)iMFK4<`Q-j<)xjk)9j&K7_P~&pEYoEcN9s}5K%&3{ z@f%CM89<=ksn3o4z3k6%d$``syL#h>Vx^`#2m(85^Yib9X{%0@V7e=&5?y2^BUgee zNu1V%6aX6=8(arMCF)n*b-=9>p~L{{0cd{UB9bG?LxIkR5f2JOv><0+Naeu<7M6il zKGm;914F#DD~N3y$Q?^!a=IL-{Q$;zRS(&ad^I^GWn*tIn{`JWq~TzrJqZjfEap%9 zh*p3*Cq*ySO@Cc2i4!ykAUM03m#Ssen74WjyPl?Ax!Q-vXTRNa_paoFHH|AkM1seg z5KA5KlZZgu-7SZ5m;++pPz6MEg6$G9Dwzew34xb9XhY;|Dxkh&Rc8P-I6cDF2ck;G zC=6FhwjyMX@BaW|e%N)eMR$QRN1QP#85I>34PL`CiS}@kLrNPE5RkDa1LqA0WdO|5 z+604CYB9&3=P%-_-&OfNzbPnfqYObZ5H&v87XP^DBS%n zs7~r-WHveKul&yO6>@vpRqmfzHBmIN(9%k%h1oos0y!npxsfQlrI&A(+K9=WZAV{eYVxw)ds5TwdaLEy8P z1C}A72piNA#R_Ct?94B~?Z~8J@i!iw^8=ZO&u53m*g#rIQKKRJ;w)-I5w7g!?bO31IR7lxjjSq*y`} z1;T-bjh3KQ`yi|ZHAGoak?~Mr%8yhiWVr9$Ls<9}T3K7~?CfYD5IMW3qoC<*0Cp{P z0I~x;UIhSoP}n?p@Sr5eG84u|<3dL_xZXWCvor*(;|)_;aWPCdUxcs&O$iX7!Q@!c zhYB%>!zZaYv`2o3oRoADY5?P|M9-M$=f&Vr1u$a%=qEgOqxlhV$%j$ZKIh=B8k(3e zaH_cRXJpGHxSh6T%BQsx--Cl62*hAxi4Sz#25{VV^W-MG7uK`sKY=dqU#}*Se`&=@ zKVH^4JB%Qg3N8?ToN86H>U6k}hT)T9N|VqFXC5D@I3VI8npup&mJtomeQgG$x128fmGxGdyt8VCFP zO}QXHmshBi71{x7vgZJ2QIH*jF9O9i$DKZ93gOWF&h?pKr`%ttc%No2fX+8F3#9@6 zfKUW!TP^5%avGuRAyrdZ9KmGL9Bpk~g6N~_#dpUptsw=n!-2q?q|@3Hzp((?f*HU> zr%%O1-iL-hhw(4yQ(*BF)UN&}SOViuCzWfAUSivADnqZoHU%n-o49Fyb+9{io;@pJ z<}L!$7j76W2_ONiquE(*{EMvxusg7@uoMApvFgtAgK!J;8%iDVD9EJd%t0$}`4(E| zlT+Ynx10xv1IT$x`sQokzTFdpDWk(zVQ!Z;O+e1#2s2Oa(c#QYP6qdw!5|Pm7$)C* zy@W#Ixf8AKKf`Vi2@MMvyol(Q<&(#?oP$I3aq7_7w|6SAO5{M<4t~^%5)X)xi({-6 z+C!c_kPbIYe;;7q|3hLSJ%-#czN*UM&;+as|PwB;z z@37DJBTbfTbl6yEq;QK z#L7nT&!?UH$42_1$7Q^YeuTLJJa;BK9OBH`@xun*j5{GjdQPnP}t@*NW4 z_Dt21MNqY-013}hx z$N^^+H&0BMMW>+$cDlrXTly%afpV|@84f1<{brTLl&6NeS;(wI6nEGwd_9^ z`R<}rnCxKrRQ@qF36ij=DBwmgir{NL6d+;1K-ofh1Bem%Zpi*RP;FkQXL~}xMsooc z16enW{bDoh7Oty<;12s$i1QtD^&P>ug`JUgDl5%_<;QV!mi~l zUHX4@OnT*w50=1i>99%=nDa*i;MgqdEHi_{Gh%RviCw@#1PKX7Lg9dQVHoR`>$g8( zfh3AYzn!2S(fS0wu^Zdl2pKbMF(m{#0GHzw4S8>iIPijVXQyjtp}Cw1(=hfr&Jk=X zd3boX+W<=dhOx8MMD4^GOfXvhmFe+IMoK^j=knaDsv{^y5KJi)EhW&)1CI#;!p&>f zB)cr3JO(|#1aL`OAdCSr58xB6Izf{V4{0bSordp#Zdy4>gfn<%#TkBn47?QNL}4>z<3@BXXoA5Ag1do#p_h5Jp;>Q23rw0 zI^ZOs95RKo0XU-;Yj95~3|;T|e=UgOs=pv21Q06Cz_$DqARv12AnS94juHs-$DnZq zW4LW&KuX`-fN#rgHQ)K}9UAibY)AYAWQ0IhA*P;;s6wU-mb%I9m=8hm7ay+$;JjiZ z-10}^kmj;v1MQVCtr>h_G32faz$w#6#Ey4?vI5-*G`AT++a|)wMoP-B#S~86B}BRP z0^R|J!T$gunUIhWK;j=Re$DUxWcwv=$|nZT=(zs-&bO z&3qY5aFLZOfE1ZQt47_DDF+c3G0-xlB*tbS9c?#PtlIred)OGB&v7Pg>_vgH7&O^H z*FnCnyjuM{1*1X2G5rQbW<*R30Y3iH{QP@+RDk*;*ld8ljl{e7$4G(X8lr)v%knei z=V?76lJDDNxXGBc0Wef6F@pYyJ0_i7^d8cPMk{)VRCv%?4hRB-Q}NrW6PazsgB36J z!AsaGLp!Rfu&5|R7wD-dHNZ0P2nkJYqezVF4f0p*!a__}Pm3*jb-K{m=ni(_-AZGl zqvSVxDam>b$Qr`wlo{J1TBEHYoB@BdvyJ}uT#t(f;SRnH0SI@Nff~@$*4_Yt$AUcd zM?emZJmFzRM@B*%UxgZp>EpH>boPM+10eo8(SWa{vmlzVVVQ!!WDnF_x&T||zELwV zF$pu(4M+hF+ zD}+PLlin)dTm@lNESH5~N>uV69GL_k7sQj%uXx3g{Pig7(rCaf95H(b3KFQwYc?A3 z!M;$=4)9yJ_!<~W1*3z&48~6xjx+?~XEr_xW&6Tx*$9jsg3b)!suNkH1qFO+;EXZT z*Zct0~|ISI@G_#}3g1)*7dcDsym9@!?0jt%5Wv3PKE&P8oe- zmXx1^FX#vC*?!E-5B&p_3IkR$=WBh7mttOURm zIky%>>^IkOgJ*L=?)Y38pzn85vI`sQqB01uZ5z?)(O@}ASUF&fo_RZ z9AQju7UG_{EDvV$05T#9W&qZ*EX>X4p>U?(G4aJ=T7{WaoGMG}RPg)d#c&)Sb_r0F z#sJheO5A)EWHT`Lhg>K031@7@^%({-l1r{`|5e5zRg61Dz9PdkyyeLLmu$KYNE2Z3 zz%GYzWdfw7D*YLU0^yXCqXtc@i0Xyvq{SPUEQkxOY0R;7!S!Gu4+Ip@wj!#-KtK_^ z1D~X|HKwn@)ty~k%x&W5DRx7y1%jgC<-4YJ!vRLKaP?3CV=B1qc+QcSbZlWx8)Ow~ zI68(x%;VPsWH@~pJG=p50@v8s%ytxy%d!MXCpcjqgaYRi9nf--oTq0N$IDVI$ns(@p-zRs-H~mE?ae8kUE1eFPi-wRmc>0~Gqtx`0Cb zl|!WK-6WuwPQGy<;0D0@{v^zI?_FnB{Z0>A^`DC2!v|8t$Nlp{L4zZQbCoOmw!z=R z?5EV7bA=r7a|a#&n$rICPy6n<@n3`h7X*L59}rJJOQ1h*kN7W8==`UmE_WpgL@=($ zjsHelU#K%>`WHvaa~+Ln|L|n+p8r+G4`1JO)Zz(q=U+9=Iz8ea$6m65o0)M{BfuGO zvq<7^|BLb>B7+5GMm<*dVBNw49%&OFkY25op}Pe(hV6T# z9Jq@A#sbKHb7rJ)?^;9Y@3fYv?NU$ymFZe7O{7|SW=BeUkAwNQf}x@rs>?hHEQtvE z@$|=7sXy;(oeyh7zvmdbf??2~I!KXH-90Z@mmPok=t&qhew?Pvbu~ zhXe;#992+TR~LK$Lt+T#98L~V>Ba`? zfD>zRqQ{lRCbCtP5kGwi5w{4^6={A0ND8#I(Xj}Xlg+y~H2D=MQ#oj9G#XEaae+qwtunL#6O^nS!rA zCuw|Hwm!>E>GbsLM7w&qUb*%32W2D0ob<3crU~1{?7ScSKQ31G<;aTDF{xI}>}y3_ zFT1%iP$6K#lvDk@k684v#@*3Fz4!qCC8fTW*8UfKw#P9)`d@z>uI>F$siaw8I#-kA zf^WKVW>kgkZ)7y1>cPMxm(@|1Zu2E$4Yg8ZO=-HkkxG}5 z@u@dzS}VQPLRnW-56VoC%9eSJ%6GzUGFhAUh|!r1+u2S9)W1=y`Z$^l$-JuF(rOZZ zDIweFaK@vgRYJ|~mXR}_f>*7)^*JkQ{cA%No=W4^-+LGR9d4ABnkbdolqspIFe)() znP;hIs~0MT<5QHinrDqtS7@lJm==y2)#q4-%qSVpkfkv)s+9DZ8&6d#jxYC!(-HH% zwCn1KzEQ@Ju4p&Eq)}Yxcz1eAyIhg?_U1-s%Vt7wVE7NE1DR$~NxJx|X-+jS=iF~p1C}ZQP4Br14=!I-o<-5Fc{uIh}el)_z5W{8FBbBZVf3u(6FZKYO4_Rmi1 zH&*n?-eNK)hkbQoUhA3?tIye#&Oa8)4x5QLI}jZ;&pO^>IP*512|W8auQ@1xrV_co zGx^Q|^?I2z^J$wLg|E>}@0q#d?kUNf+?UfH%US6%(+Sv>hRgW5x)KI68AZzbNTbZ7 znYGFf?v=b4FAvfPssm5jy5nX<{DGKl-FOQvlS<2#w}L9Z7V^il?|M(y+dh3* z_r3}dTGRUsck{k3hTd&|iZnhh+tL66Q(Ni~1^pBjN2N%-j?wYbrjJMHv$NP17UIZ0 zdWA-YhRxp-f4#l!9VrkoQJ-yWZptwYyPJnGOjlJyKMKtLK!AjMVvN)aPhag2TKymN zDj}aQ`)490h}4(@PagdKE)9*iP8~VIGkksW{h}Bfwd5O^?~ZtVdaLVk9)8fCwdbF+ z2=T9{+Qt9U3g+7Tzo=XPum9DcFoTWxhk^-rg4mUoO|6J0w!k+=xr#*Ly#wz~A%?QW}2B z>CUMT?w?(d5j;DA4n+5`uFVQTBGGjH+O@D-QjgjYc?Ju;7g0RDlr6a0e@cHco8mIM zB}1+M7-9GI4i}qyOTn;C8F33DgW!{~ebZRZo%553JN){9sZAg~RI&XSn+HzEjpi;S zDtUQlYq&@fAv!Nz$0w;48)~UTx#D_P0`zAMY z8~@f%{2POVl6(K#yUTWTBy7yGN_I=9@FUX?`@^M)Vl0D^V-Ys{am~GK`WJa_=?YRo zI^)NG=hoYp6H=Nd_u2YpcHn4w&3OlG+?Df`ovd2oA}pYg3Ga z@1I1`AU?0@>$fmNA_6Ru4qc1QYWW6{4PCe){83VmLQU?3-T&5>gEfILd{1r@-pdc& z@{(<7?;Q?B+<;B015x!;8f80a&E@R9X=5gQcpR>nQdWWD75iBVnb~YsiIce)gRRx-Xq7lrE ze6`A#>iz#6T4^apTy43G31$vj%-r{>0028xPgm;{*E-1d>hELi~EU}ewg^8nGBVW(_M)r{ zVj?h+qMxREOwmX|#bZY=-z?4(cK+PD+k4&v(yziqIP{OcF(}qV@-Vqv^1ypry+?>x z|L4iI-XS9%430#NTXE<#%i10&#n0KHzQ%_?e!XN||E-<#f9dr6|NrX`w+86?|M_lA zFSx=yT^1<@VtN~7#N4gOeOluK>MGonntR;BQo=Kczk^Kv%9GF z9`O|i=T{&c(D+o6uLrw%Xkon0s3teN>;WVO zM&dmzYnTPC@aM<)YbahvG-zTtBeeW86n4}ke!jlG2xX08u|XWZ4-jhqyqf5JK4#N1 zBv~uO1$kW%T&dyW8vLtr{6sx?e=yU+R@pZfExV_d`aVZn~}Ibt4lT7Y@w5`(-icmOE(_k}rdn*@mQzb^^F|1bZ+Bq1TbzQ*H?)4}G> zlCqBuEjU9q%O{J)kwQ0P#_tPB6m~yfm%KXLrluT|%V9X$)I#f!Ojm6`*ds8Y(oJJ^~Dna3$Hn|KYK6#$4R`ig>b?PZ09 zJdboVVZ|~Ge2iD)%YkaYrv}5OlA}` zvSURyE@SsfOkQMDBx>PoJt95)a+iOt!05`ih*vHTG{Sey;sVkXr=7+fr+FG z7u`vnyFR==f!4I#5ER4noZd_@9;<=0F>8Y9s1F(~AJz+}o# zQ_j;EIE0ZW`X4QA;yG)NpwffY@^I?CXZVQOuSyc+xMZkQ?E9n4Lnx26kGjv& z>#2stT>IGaP$7)&5^>(CJ?q!Bc@=rr`9I;sxF6>uSWF%Ums={FKVf(!b|leyLIVy@#au@ldr&k1!Sq z+jm1^ob5IljO5X1E0UtE%}p4e^8sQv=2rzpSDKz%PKZ!QD7jGM5{pr zF{f$qF|Fo3r$c&1DV~m)AsD4mJyvB+qgLwI@AW<^$`AKG(TzjkuEV@18)1LA^IZbC zRKoWe*dKI{)ojrDbOaBh{vc|(C_0e)!K{iE zfODcaU9^+wrbf^)-eFwb#lc70epbEp+1=}j_bdI4wAoc#J9`+E=(1dnEaY{L0zh+%)&d86_vbU ziLB=Qd@nkh<kWqzE5w$vbGF1R$hf#}Q$ee^XhQ@H2@Hr3K5F2s-Lt=jor;9`=hfFohBe9jDbBh+t_i3a*Kik@G{nQb}y z7RhP7cpWTuH)mR2Ep#{cmlk?`U8?sAY*~U%+^0_2XnkeIxvo}33*QpINUD{X25vJ< zw#cU3-5`7Di#bV;$Ch4;c^}mKYl8(RV2_BN_--=7=((&2($tlk^f&Y3%#iCJdSLsG zRh4ShSfQiBc}~O3{M|7Ac7sa=r#QFO{?$x|pw#gzWmXHdkj+xBNY>`Mx}L*qg=VmU zH0eASy$%pI<>eH~O73Q4aU>O@gr(6eCRz13$>!RzGPBDrU(!9|38vYhy_@VyyE7t?ISqJZs} zW)%qoEV|Gj>Q`v{0DhbFs5)?wTzi{skQdaeu`Pqks9gFDBzR;sV}pbHb(bBarSrtp zI>Ymx_;4I5(w$*BoxlA2SWj<(U0EnW$x&H>P5G0}3GZYe*l8$~h2@Wlfw;J+ zg+LNFe(2r_jTpSoHE9X5I&Pg^P}GB4OeQ86uMVphK1ViQ;!#D6Ir2e@yH2=!1k@Lm zA2GfKp?K`#>TreV_HNJSe)FwjOcHJN@~63@GzXxS0lv0dwMelQ8xEK7M9MrgJUriL zEH0lD4SV!`;~HAz-^WWXDKQM>jN}{d#U%(`5#N}XobO;Oq-G3B>lo#AyxJZ-q;949 zXxiL%C&~}8LqXF36IU&#^Mk#`z(6p*N535?#0QF*T#-jx>p@9GM0q9Hjc4n%&?7E# z*jXTS|9Q~{$8)e}hD(>&^jD7|02n!8vb6c!N>i`2irl*2*^w(- zf5lb|bFI{D#HaB!L{)KN@pFi?G|b$)%VT50#TfX zh~mtWODTLZ=aZFH8}Aayk}@OGH<0%Lv~inr9bN|h$wX|(Z6eTN_GJsCvDvDGyd?TT zb)K0@C0HG@hE|pKU?t`NU1Y*1XgS^N#tMPk;f$9&WP+e&f>9y=X&}*#z5JU^8gij}2X3`5v6$1d8QAzO#XJS@+qtC zV`o_`z*ByPrY%@}Wc+x^jTJl1peXgx@x=>3d+p5nONY~6xqW5c2()x1~{RQBZtA$C<7r zt$E&Suk#0)cZ2@7_vlOClhA4wA#>_~YV2KZHeR?=F|zgP0|_|`7X_PW1c>oiK@TgbGAV!Ikl* z4G}ya6|OzJBn#tv8)O&9vXI=gcXw}E;6UlJe&?pD*X~O4dl_B+8~FWvR`X5Z&_c%H zm$-s)*57}-@QK_Wr(iDC7tAOk8DcfabB;;l`CK?&OszrxEKKZMe&Z@{em6J*Bqc@x z9ezD}Yc&_kqbz5?8S2|?LP8ZD9+2Q%HW#kdm7-1n>0t5*Fe#|ZD+=(fB^|AK0R8rT+H zn#w5|I2pt9tvvWdF*xCNW)mKRV^_r%j!Y$ogt5mm>PW~Pp(_W9W5@x)Ef@L=zUis- zmA3q-7u7HwZF&)hON?RRtvrTwLtpicAMWBnma)xdXir#{m;&g8i7f8g5h5`R7o%5@ zZ%T+eg%F=UJl{Qj=Xf=Jb*$3hw5B}QW@UV8aWRVH(Qvh$Nv2ZKLpPd*j`r2iWLie0 z9@W*gwLn(x?lMgJC;WD{sB^bBNsjrWu8lMvJ@*1WzOfdEad;|N95gHqSZqn|t{8;Q z_m4wYSBX@Un3S@N-Y$dl@D_4+=K&4*;KH}|0aH{r_lvA*vxS9>svpX8f-)j!@1dHh zJK9dU#jh>(8v3)SOlJ;%$g-&Pi=tD%UpBBa9;T4dgUz7K>Gi0qHBFJMWx7R;#|2sO z#Us8{N_HqXv>HOEXiR%l`vi{HG6(e{>01>{Om$CA z+$vX|&#mO*EjN@3MBIN&MLbr@`AA>0!g-E`PTPz4rP_1e_32I9>^=Da3@LGC1zF~ zXWw<|3~KuBSd~Ha=xeJadzDvAgm%_&sY%YdP$=dnXgDfe8>BePfVTfJnj%5!c<^8( z9wIF*6>gqPUp5%$T3T2rF*&-)Hun9gs*1{;gq~xR>c!R7faBh8^vC3=*RO*X4*eBK zO2$Mr?Gk+n2yT$=-wUk)%N~2HwM<2A0f(-34F}5~NAAP$O*J_zA%xyKKf#fJ_z3FScIEG29(32;( z1FA!M6gkW0J`=mY#bQv+u>9hE`5d9HsvY5&E{~9gik#qFw6O4-ooiRFf(|9c1BU*7 zaGWVw;vi}AQUYTtElg?jVxhZ@1A95{^(pmUyFS66GZoJ?%AcCKY-FqV3me)V_$s!B zGh)b#eh|P8;@iMr*@46T?HtHpLN9tGef1Mf-~-(__2knTkVE`v_MMR|NzECqJ^K!s zY8T_e%!-NGnzy4(J73W@EjqM+ST7XL*k1Fsu&J(o9Lu}){d2u|NTZ~^U^BQ)XF>Yk zw-}YG9#w4Esp|h=aiGkm0aZY^qXYGP_Br(D?3kFi8%!4}=d)+;RL71@}qRMs@+)f9Y-h=pMUOnUGGWxJ(4xGdFOnwNkaSg!)=;i!X+NNI9`W0 z!#CTtD<-fqH^dY>#k{bDckol}gmaW~j&W{C=@2Ts5b@jJij2mWV^9c$GtRB@>*SZM zYd9!oEJ9njyqD508Fh&fbaK2#wL6#u>(P+uM_=?$PimRVIkslnjwYh4#fEe`W$0$- z>%6|J$!LhWy=n9wDoSnjIcLNIBZbn0CtXjz1Wa`>C1^oMUcx#Zi*AdDlikL#r{2WO zyLaz~isnE-m*P^(+vP0CXnic*aG{HFBC{m3(sI|2kQSfRhM4%ZAdRC*h4vLN26_#mqJ#^nJHh3i6y36l&uBF6^?ZOcR-S7= z__ZTQ^~!#bp=X73%su=1_**QiWLz$C8$TCP=d_34eUO*?=(5Bw zGCZQHzFxMu+(5%5FNf*JGA zgV^ab1>FaNzg+7ir8E;MhJyufQcA4RqPR$}+3gse!5B1ZJ$_1yyarvV{;-rnmuSrS zW3QvE43~!YNVB=#Bs#_))|dTuw<8oe{>??()6tCEeo8hAn^U zE-JRNtKCitd!SZ&iJ0V)=cVE@Xl_lgj@|_m2obW3)g24N@0pw|EWWm=@dX9Uq!c(S zJLw&GzVRJPXL`HNYjb3$Rv#U3&#N*r;rzN6|HiR*nYOz(FM*E5y0Brv8B2aaJ>AJ9644_I~-{ z@@vJ-_`SUNkLJ(Ew6+3qqca)RwDKaNv9CMq%^r<5Gv4$XjUP7|wD6d{NqVpRVrfi}^hb0oPamV=8GVWc_f_H9#gZj4{^_upw5r2C}Uq;vupY0AOgrIe1c%@{!n)D1)4C?uOVr`yv} z60mpIyOMBw(pv1Sio+z%qnB8vE_CCi#B!LVK5>f`w3d2_VKZj6w)QrHX+!9H58E2M zXzpuu#mDQ6Miw+FnCG!lS@P%!9C$XmK<9m z6Wk6G($j}db|xtBd}3UqDOz^l+@eAyOX&theEw{0 zSnxf2g%VW;wGrB&o8O+)YK|2=6KP(tF}QfB-_FzbY*(tS?_Ji9p3dw3-Oba@J@1Cd z)}ZY!HTinxt)@2{3|n(;0v9MJ@PHTKAwRC!*Z!XY`L2;^szUtI}4WidHu7wpk zK|0jW+e>`uF10mW?Xs5zpsOZVYk;UgsfaTT8&jJ?(Z=`v=v2&Ajf!Ot7ug4IjXYlr zDeRFs*qwBuw~T*Ae-zI37Hx#T?UqX;iT&K%T$=TC*OLsD%UHs#5v~6CwY$A61)aWpSx@UP z6TTdVTdeH6u(3K`skCu(k4Je6}d z2TZ%qS5>C=4b>xq#)rjE2UZ`TU-X!14XgJei*Zfr%`wIy{9LWPx~e)*(?3=fC38pk z{sq-{H%1=e>T}^OS@?TX#(kBJ`;cM3Sd6P$PeaV74acS@wmuUh-6hcx>Lo5WspN-m zXQ65VH~PX+q^rur&KpL3FD&$oay;8Sb{aSjT~ON+O>GN z-Zh0v>^Am*$BozVQ@o{?h&z|{=KJK;*ohKj>4V~4kI?gN8PLZfh>Yfs3GE2=UT3d2 zpFp1<*-d{}c8liYGW&<%XfzhbHxT1-z4^2Wy}k?0T~g68$TomLQ=bf!<@Riy&A1lcB5N<(sX$gjJ)X zai~OWB;BiT_GQ863BRK(`O^mB7CIpti?~*fY~Z({&HdUP!D9O99wa-@w7opvc@j5s zPYK1ZC{Hlf+Z`r`n=dw+z3V01L=(WIBYVGhr<3>xzqs`0*QXAr(cbe)2bGD^Q~ zW@TghfDANOlpn@wO$)tm&N9jjlZw`SEhF5RO9@dk@eO5?A$2VG&eN(FwnFRJI9NKv z?bYu!;>OF2Srt~)K2WhaL^C0Er*vjU6FfWbPW-^$ado-bdnDs-O1H*2AugF@R-NJl zF;uZ)nj(y)ely{AHwkJE5%K}*(0g2t@vJ(Z>%LGw`*xs@eUt07VFS8E8-1~|-pNQF zc3Mn_k!PmAdKANkMRAKjv8x`rqPdChRI|X6oz_N`E(tAQOM5pP7kVIex3+9#29_wC zymhJrhEed4Kw0Z=|3Ec{QtG|q;Wri0uacGnbe>@nS8a*&Z8um#)xc%}Iw$ae;OS28 zKcWmCTr+z?8y3tPdtjnvlapPg)1LuV9&}#dC$0bO!|i0##6x31CNAOp}*gbd}lsL$m@FewiST6Y|m{LM#Uey zf%ULo;BW!-jW15r=6Qc6!H`LN%s@;GDd__&mp~)YcU%N~rc_LyUpi{kzu5_^CN!RH z4+A`Gu}+{Zng8{1qFGsTWfXJ*9*-w_-6cQGktwK7jv6klt|n(B>1zvbV@ZuItzU~Y z7)s>UVNlNHGW_WGe*PP-d|O*v*M4{P!zH2zM;|IZS;;4nepGi??xGU%CRVtGR6asN zRiN~`c|wTvvcY@e`bd`LQYm3M0opZmm)VYpJd=@rv3ayB{pvSV{2Z%{^WriSSZ20V zkO}CH9i$!e{lcDKX}@Ht`bMl#bgPZV86mrNvnd9fH<;8?$`PE}LN0wh&zIG770tf5 zptQzeE$%q8&KMQ3--wafbTc}Un z|GHtnbLlKfZMM{;_v6!yMJ%H-<31a?tnphjSnbQom(S$VnDOrEI&2X=tMY#`3XyYF z8@0?K&@7`8RN95N#!k_Sjqg9T-{T9PWlY~w$GZ7S4b7k-vfwL(|W#ehXYBXkJ88Ly)w#=1y)I@Re)l4iQ&I$WR zJx-t^*?^*PchTON68pmDo*C=z^KXfZ^RHGr_ZWlQcah+PD3j5!NY$GKl2fEwraS(3 z#QmWB7!%8r7~@EoayXP*l}iV#K-!F-ZGCN;sGazxZFuJ^4Z6GNNjlhqlCG0wAl-jh z2*$L?T3TA-#Fc@L#c2Ew`)Z`u;w;f}1wA*dmwH8l@U`#cgmv*0nt!gwW6!)pA-r>G zhaGr{U|!)bJek@W39?C7NW0w~P2N|cE{&VDvA58L1sheXXl1`)J?}+0M16q|T{I*> zX?c&6BTVAsV~D7E0c6A-R6YUWNa|zzng_EmL6BunK5%gDDY3XAYbHxh-J}G49{nTa zFf$BvTmlC)%J;p73X$>Dm(s+uDv`yC-(IeEVZsfCHS3RG)_J&0{Gqbw^x3*-vHoQw zB;%X(nu*hUY9Bk}-X5P0v2uzRK#%AYLzH}6y2wg?LB)y5I^)dve)^jTW?AC!L4%>f zgJMHgD4J8nPU}t7(=STJ@g&I4-5e=hH8-#J_(Vhmopzd~Rzb7hjHnrfvuO>+tKXbU znEih73GPhhZPd4TU2k!i^xIMLUiyBT?n?Tqu5Rj05k{*d!|V4313YH36%HH(Zr$(B zGczL;y6O9%&+omaf+$l<)_orFOy@f@xmq@j==}sLn#iH~ODDG1YWunH3#}HUWYxo| zjjj%kR=G@UmCSv8hbA6dcj@vK!yI|lXBa_p6p9@sxlTDfAD;VQcxNP?y1(M|C?~fb zxYdT$F!nN0D?sXHa2)+2O2t~)M0;D?jk9p-(+9|frS0xf?%o2l9&{QdOu99w^>p`& zpwIDIMCPogO7Z-j!#UP0ChWpv@84K}9WgQS%ro-DaX0Uspro(DotRj(9u06%;uTCO zE68Y6OeROAUl+{-GXzjfBni63K1?$If-<3h_kqo-APkQC^0~oie`R@va8if#9`wJE zW@brMKr?sZZi&srsrOcXVqJy)-Y&EnPHn9JAme#A9k74GS>YqfZ>{iBif7?#h`iiH z=VCIiRd8kB)6d7;51$PTsJGr{KaPr`x?ZXsYp|KV<U(GRTHD7Ajxbnm(!6M4gM) ze0_z1$I5X1h>mXJdPfu{d6{{l5ewdS<)*roWi!ns&tU`DZB5r z31EIPm<}3$(3AMEC_NzXc5T-aCl23{?k}}sAEvECpHmk^h-h$N4|v)Jn1q;d`2K`8 zuFw}Qy1rcKn)*s1ff<-NQe20~SMS)Ysz=>I!eo+N25!bc0c)?Cjz`>FYWj2iXP%;p zk)65CcobAs`W}&{=z%M^d;1$9Kn;6Yu{1s#JoFy+n54)iv@^u z2+}1j(jX;WN_R>qEg;=0C=JpfAtl`)($XQ_-67pba|U1R&wZWqcmFu&I_r;KfUd<_ z?>pxlV?59O9Igv_`Luh+8F!@d+;l$wsbQp#DS${QjRG_K-vh=!vxm&_nBp@k#sL}j z;lqdC56-i*$;~oRue!r{`Y($geaogi+Ol+8;a+7ma$dh!i(KD)f#Olpsqyohr1O^s z-2SwS%YElsH*Zfi<6jFWtZvUL7H1w4{V{gjk1jmiy%*9GnL|R7MyFLXRB|>#f;L3@0cH!XC93+2AneyuxZt}F6@qP3aO7qFRo>qkC`#Oc+Td2d2aKkqbRm` z*`oijpj4bCmRQ6wCA;uEm)E_QfyUpz0p0Hv1-4~JZc{i-{&D<6!EUOH#(VRbQK$NE zF*eYG>FG5zl`7W1owZ4<*NY6b4?}WC*GI|Mp)|E`9Y^A(@*uVeP9bUP7%#xa7PsK0jCu ze_h72ACiuNBTd1XD{;sEe(Bqk-`@!I!^rp23Ps+LX_7QH7);cDa@qcHC%|!Ye4t3= z%(noKULB^1x1|-hVYG55?wIS%_=mgZ&UZF`Y!NgoX2=FDn?}S>^7Hks)!45ebwtv) ztgyc?${5=hNApc_?``P6*~~GuQ0?rR1IeNcL;8BBv!mXgdTG@b`#+#Yf)?c`Js#*860l}(4W6B!uswFm z26A-=VC+rfq9haKnPo>7zP0XZy3?srB zfEvZPY#!V2Qc;FFocl}XPgck#@dU7AACeAQ_BPadG>S7b4;}q7{4hs7=o`n5cBQVJ z2z_%*enY#-C_=)b)!UC|pPNr*Q|G%RwP~7tMj<2eMLha7U2oGJe3%Tw`%gh{S1#V#9Pf@kK2^Z_>z38QNvqV*8GOGki;7KoAlEn(QN6=W)iv{`4kSb z19gekZ5@Q~kOnps@u*fO$2%}JsF>wpS?bh`2^&r$o`(I-j#JRDVG8qzKf1Kfts|82 z*a<7B>|^d-)$V#wdWMFDi<-ZF!*cVxQ~vqaW&z9iCmtIgJ_g7pJ`F-VLOBuMUw*M9 zMG%RtA)+0PRkRRCePxH-hfrh@%j$d56@Se-t-X%jsEUJ|I$7>;B6OFJyFsW3@TSWx1SDNZp%;p3#@xBFJfQF5GhTqN@q zueLsfdY}j=&$svG*_P@G+rhomXO3|%GT&o@jxV6J6JFq~Jx%t#Up-Iz5v6+KY3c6? zQbqYazpiY3<0RMOB$XMQa%2uuTBxuk4$Cq~8lxoph)+yvj^y2S2405-zTaM2&BHyz zBu+M1&|;H~&@#DPxQNK~7H~U8GNzo)tX8DU648G81_^A!D|NrrI<85vz91hE*Je68 zBm5$ru1n*$b)h2M8p<^bLo5Qh{;|CsQ0PEou}RE-jZ=CA%>F5v*rujz{j7O=*T<_| zUo7JnLB5jA_c1P?ALPB(tJ@0vi3>TyG(ph}@1ae$4F6u!{O+BLC+~hr999e5+nx{; z4=5G`AMm1SJNxQHmh5e;X~dTAgw*%&Oq8yx&opXb1Th30mv7G#d%x-{t*k!|6WG!1 zd#NBxua%c=IpL7-?L|_B;TfKYJ%woIvqVH*GP188UXOr0rijlofU}B2`QbMUnJ6K~ z(B9FM*7+Bvs`O**B_?*_d8uEjf_BJntL-#q&&2Z4Uf8*7BUJJf!e5Gk4d#X6 z;x=uQxw{2kbKa4`ep9Q*RsSqH<_J!stN#j=w*zkNO`}xv-#p|!zYT9Sk)*gFVUY0I z0ZRK`eQ$=yoDqJBTWK4Y)vlShy8{ry9I<@}Bcmiyvw7C!bF*X7PJ z7*I;y{WwUN$Han<%1uF4!Ju|gZd{N*m$j>d}{FNUd^0Bkq zfl~Gu7pMC&)0;b>R90;9Q-3-3^gFY`%uj49zxx)DF#LSU~{jbg_Qt+PR+RwnMy`Iq)7X=tV#*| z^#gLB#$<2PqMu^CqLI1vh3>eupK(y;TMXl0!4nfZ>ML_s*!T{cFuKAS~Ur!ZQ& zg;f*h7!Fsow^wvZkB0MAlyI0x_&p#NeOt1Qp`X>#4NuMJU)9nrZ@*|-BC>^uZR=e> zi&2+8Vw(QZxp127B9!YHyK}&A&|*^LuV&d~VBLGRMFL0n|% zB|uXPrT8cpUkKcu?gbjTUXy&DW9!nlIjQefx)UXyH}v;YFfyVX9Smj-go&dIBf3gZ z)d2$M^UOyh=Zn)IB!-84&uFuhra+9xu)&&t3(X!x-=X)Tm~!HdA&0ZpV$GJ3GwbnQ z*~J+;KuW&Zq<$)@&N@0%eWhd>xtvAoUL`k4Uo}n4)r?GJ^Fa&2rxG;ma2j(x>g?*? zQijBw{l#|T)i{Hf>6yAOtnJN)oOWjWV7pJp&yugTSP(~D6l9~%*D*eJa6}T`V5vgB zv1__}n<*L0hPVjAL&E$8g(+h9{BDqkeLP0Tpr~kR&T*D24GBSi3vo)sUh;{_zXdka zt+1_I)Jx9@EkC8Gg>q!SEx-Z_x2SE#7p(|NRe~q=^*+8HH|FJfNK5uclc+v1tpt2& zvE%vR1om6lgq?@3&V6{B$ z^teDw?uM925$(LE(-g9izV-M{C(=U|T`l99{*JGg@leOF9w(BH?S%})>L6hY)NZP$ zSy+npvBqefp18d3h)eCs915-FcW>EQY@6CljOppQ$T}LdGx*)zD@Q)>vb#Sdz|X3+ z*ivOJeQ<1O+SHpM{AiUeUCJD5kLCAJ3Gbd$vY>~IlT?{(ju40~qOc-_I(j;=3A8U2 zbZmCR6U4r-GLXMH9AF^~pj|S1Awd(Cx}iFkz$%7%(de_iOYYpS=e{bbY`fexx3OUW z5F%4k@@9UUj8ve;DeGm7k>U_;ENwEOOBj8YPA6NAh%}H6+$LHqpQIdEi}XXOoD42gJQwO6mR%LDW8#?B}oz_*z*F z7QXjsHHT)l=C7G`!6XX*JDDYZ`o9g4^ETlKi<`h^HxdYt6z|B$!sy0Bd5n>m# z9R0!b8db_hLX?u-(=NghbD=#!X8XP4?RX!PT%kqWvea)c8Zw44sx*FrQ}v%70c?z; zjXqS=r+Xszw_JoS!s@50v&?*$G*Y}pLor3Rs$ExTm^I2=UZk0u>5{d|W!_~izStdi zw5#4&d2q>7TU#sn5#uhNBh$+lO(PJHmvI>T<6Z=MnOc^jSei1x$fBa-pSp|BtY?O~ zO!GR2*-E*8`T_5EmZDTYX*Qk-cL4Xo$6TOv>*`L!(ba3ED~>btfIQ_LT5xnzHOlCp z`Q`xfJs&%{`%xdd9NbS!K@Sn9Ht-YSVsEJ{AR){XQKLQ~8I21~=t%+=nj zq4=j1a`js##8UCGZGJmcR*Z@|fL{j?8c4In`7R9ZzZV~*fq~`v>A@M_KICGA4jMj^ zap?#Zi8M^fCmgz$vb`{9%C_@m?f2^Aw<5%B4K0EdrSE8lvQ?$NaCRYA!Bw`-QBvXT zH!rs2V`8TyOmbW6eh=X-t~Z|$_rJ*|@tbx8N`xraMZiYA9eMaI5?#0*tC{8nzL&(2 z#V3m< z!XihCQzy5xn~V>vcHIu!=0;n*2wzL?{vum4Uh;cB%%}lb4~v9j#67SgNcE+6O5?O8 z)xCUwGn3sk(rO7^sN!K(RKk>$GowkG1xQE5oD|W5e2;rejAxA1tKC-paVVc+sjw7H zCf1CLE#Xs5NrTa5%>_1znoL!0hjVXjx2KLtE z{BwyANR-1OW|8sSC*+J5novV`eg{yIsG#enZp0rD>imgE8l#i+hiw!+jI@2war02 z`T26h%sK#Vjbm}oPD5u&Y6W=^g@dsWuFFb3e}DOyhWs1s7s_G*IN!_iuvRkwMGi{u z^*EVJom#BTkvy$3(MF+XKhMlZ3Idj6WQjdT_1s*WjtcI<;JZIYO#@pq2imk1&ikqn z=DZFT%+wD71=JEe`<`F8p8ClXlZhi@TH|XwRzZ&t4;JY>fRN_jV&|bpMd5`%3XYbT zs59AqM=G0xOlU6?u@5!C)e0$Pgy(f@mV(5&G|_x&@X@#fLo4lhIF53@_G)LWEjG=I z)rpJ;-TMH%*R7q$F~yz$XN3w&wn6io=}OS!B|`p&a_+H+3Q^s5Z*raE>Nv6-RKmg7 zS%1nCEs?Mx$?-gD`DUCF(~Lssv~nZFYfa$Ax1tt zL3zM|I5b&g8@(D5PC`D9Wz+j)DqA_f^wlp;W8-LF9d>yo+@O(pk_a$2`SNzS+HF5v z?R_jsj;T9j!D3@;d$zvackCC+9~*-&=(_xg>d$UCjpPats&Y?sp>Zm3&fPEDfkWr= zXNyiOS>){d=myBdC^*=ZX&S)mr&G6eK4C@adbkmMzBRZy&?eODTd-p^?4A2vlS50{ zec(&_1H)Cj&3>s8Jw^{3qI~GF8!U(|@hPe!v{nPdS24w#-bQ7jBSSVR;Ns`33!Mr@ z_1SUJ0!@JVfwmgA57GD+5F#n~VI7GzA%(W8j2QR6BtrO@8rE`$N_Cyf zBChUb--P3KudegT))bT#3vTAa&+7;u*eU%VR?if@nQ6(MP%(-UTD9*Mm3G-hqzO82 z&Z{hn)a&Ju(e4=@+rvo}PB_D0?aB68T%!*WF!&ta^ls@Knr3qkAdTfvYo`2UKPOH| z!3C!d^KmDqC2z{CX00}lva$Snz1fc@DBm8hqOULopTkMf?m$3P)Uv(b)AiuD*G8?> zg81pt5ZWbvpq{*~6o?|qmL&u5{8d0wze7d)M75D*9tRMZ9j$^_E3>n{>V|W|AF1q} zT*nV$X&*#BR>49f@SI<>wsj5emuZ^g@AdG!Pzjz^>r`<{0^)8G8z0}dAZniy$17BIW4vahrRDC#4vZL#auoxA zODgZCX$(9v`6@pZrYLSd(nd&{#1NejucM8&F+)l4(BWR$E#>|yiK3exp$e(3oMxjt zfPyozX?VZ3!=%O`nch#POg)jU0(&eNR?RIFT4w}bHhy|>R}ME4G+}8AZLRxKDXB}z znkrh0^0q5X&xX(Uh8Wnb{G}`puwa4LxVa26;=8Rk^yfcj6Khg9Y)$gpjc(IE13X!# z9VOhy`(~B{kG;15?&HKyidbGe>@dYEEhz3I7&)h1kx3$wUZ!;kjt}E4r83I zG64jpOOQ!nbCH3W$poUO@bJb591RKam=gs-tMcN@_pcaU_-T{!A3h9zIs}D~PMtyX zTDest+uPFYkz9gsdUxo@L2jeV+z0o~aSo2?R1Gs|U+yKzCGopv*KVSH!Blxy_&R8f z*3Dct#Cw^9w@N7>nlU{3r3Bm>YPD`WU0f-Z`6oKUaf4Z@KomAZqTR*+UZ(lX?_@7M zKrwK(W`DUmk?&Pp^)K2Vsf&ZzDkwDqh(k<781cejOce`76OymM#2d6Z3GhIls{0t$==jbHY#7+yv{cCUmRHBmXFbgWS5!uZV_14gAK2qfUppEqKE9#DnZ z=M)suOhCJeZpxgEu|911^A-)z6T3=pdLLV-QAtYfhNTInbs}NlF_Q>12K|^(-QxXd zU4^5uL#WS}R%3EJa36!ht5*~C!N*zz;Zh08Y{&VgcHf0)2vAjEs~bJD^r#Oe9v9!T7ayV{Zp#o5O%%EE~ry>mO=kDAAKpHQN?yXyD&EoTq6bCjF2zH{_tiqoz`! zxfH`p%>02zGz6WE>C9@>ZJrwOnsJ(5#?dp}YsCK;(O@u(=-$P@Iz5f3!%BU~dTqaROw&-(&C zz>k9}2ma5dlQmAWEx+DI6jP#VL&3||EbmJUUKZ<$7$BqZL-=))srNjI^VL~!f=MdQ z#hnmh{umZRaVecgQn9Re#*KC{7z`;D7%`{T4~Mc}*+$SPQ1xPxL4@zEd>0(LUF=lr z9IJObhvssnn`V#e(-n$RJctvU+Wl-rN(I+YmTU(#7PUT#h9EjO&^HAZDg7YoJ!XjV zWrU~<0n8FyQNJC9yYXXcJSESwrgSQclK8y&a2AH01E_N5wEHRn4xHbAC^6E~x`rM#WcPm=oPOjgw? zJF4t=stH)s0ag>0BKdl-p%au;Jx=+!LE^lX+$hm|gteN7Wnv?&FAanjikLWNwIjJpuGx0~|>Dfstt!kR&+-Rpj3_d8F zNjPXlvr8K|Z7h57RX*Dv#tWu<_ih(d96O!_l6TC243kM45+a8~bpyfiytT10|J#~` zF0j1ZajxNGsf#z69SazrFE38X1m4qrG<~W>{KqQA?wy%N0_H|h6H($p&qG>A}N`R!wz`~pzx z=V=NW=}sRcOzMC40_Gk|xw9^$Afcg&AF7vJT3DO7JmWnEr%Gz&;qByM80T+~)$k|o zXU&r7Od*i+d$5`XW-HiFd0zS7{YqYULB5P>+E*Z^?mKyjZE#p|93)^Yn#J2$6(znc=$2$#9g$Ri|U63qJ&vt&cE<9AUQv zC%wI@Zv?X(87CccJeyTt`Y&13wGo2eg7U3$!q~L%`-rXBtBH{G7saU28;>dB$geU^b%Dzp!1pTGofR_6Buqk2t8bL6{xQuC)@ z-63WFI` zW#21d;O?E-PEgx%tt)N$Hum;`bky^+=aTgv1xl&GluNkh zZv0^LH~N)j&i$jehkSe5>2{50z)uokFFK{>>a>-mODfvggh=~PcGEJshC=4#ORJih zvl?X73-3)9)}ey!EdA!%bBoWSk35RmVuK?z$Q5eEveP-}KVjhGA9qX<@7Bdb9EnT8 zb4f|0n+jlJ79CaeH$)l-Lp{f@mr+~}``N!gZREQ9bi1D&^j9{bhh_j_;A<0{kr1om z*^FgvxJ1$n^s)l^&x=j?SY)j5Mh3XVUBjBfbR`62<*sG)REN0lt?BYxKiokPwP(nF zyDQ%!v*Q-->tPm>^h3&zj--{u+mCoW+aE`Yx%q=jnBQ;XF0uMSvVoHnUCa_K_gA!N zXe4~rP)0#UKuBpRv1{xgggOcMEc=>602RG%wK|^!V!Ag9s+ouD@0FKJRtoqVLgt}c zRfzJ>NBSI9<%3H8SoYS%2nDEYq$D;8W~9(@%>2xJ-dJ^q)QZ2|$X7o=-9OFPRPW z4*XT;W092FFX=nw=jVPPgC+s;dE}9U@fBI=|9NdDCn*1^rG=2cr0@(`fZ0BnP-64TYvqS9ff0)rI(t3}elWF&}opdbyi>QrZ4XRt(F?gycX|(>MfP?9bq2?t~ zq`)}aD-ptqJ#Ozdx)w}~_UY7${Z#pr?qjYe@xZu|m5q%->02pIt9&Yi>bv3YjF#z{H{N)H|j&sb41kzxZk_-ifKZmWYt?CdR-Sx&;hu3n_UiQoKPw1R*AccKwUg`ppO;n6@X)7=2u`5g5>5l1Pa^U-l$pRd1Vupndd zJLB_9zj^UBc5ynzx89!r`k6G^qAsDfsu(}`DO#ciwRWW^A8=``EeMGJMJP%U{eU2R z{llk^Ro2+C2y~0Vpez0FC-=LxZSV-bnX(;5BlpHA>@oX+?PzpwAsEFxF6b4W!wY6~ z_Q!z>9dP84|Nd7ICQqLI%^@hTb%0uL5o z0;%=gB$2}$@A^Xj_hbHN0Unv#VTb!^pvGPj08;XDbD?vnl=)JqXb~EBF!QC?`erv% zTeCET@gM=Za&c$1?_fod7}w)%V8ySD}-8D#6!-(eW{joM%b1_jc{649&zqejll=iFhX zTW8l8xD{CR&z$y`^Pru&a8oMt#vE=&4lMckW?CN+gyDYs*CT~HO#8IV?f%K{`L~Tj zhgrw_(=}%>A6*|S*#@;@Q`py^WVy4;N7^1J^H0Ga&HMHMU-%76Fq(^N-B_t9#10qj z{U+n{+`UPNJxhu)bkFJVXtOF#=DX!YkyCpyr1F%SkBFa|E_In>FNh?VBna3Zfxrbe z;pl^M&nqEOM93+#2Zze9=*RPZ5zmS>jhf{Q4a_M1x@!BF*G7m8NCatc(tzFQ{?YnpqU?A__BhZ!B9ws60oIYTR^S;BsOT&#Zv3L}urW*c>ZpPNau z8U$jjwwJN7tV?p|6BnEoz6a(fz}N%E#ETa%Ah>~ibsL(-#}CNYAd68NIr|@glz*=} z@aMt$>EQ|)CEQKEdq)}uHp=lvGrnaf)&iRb98i!jL>m93$0gRQ>8 z?!ZouhNhTtOlWeLPgXnsV)W9et#)qCq1liH zgG~z1On!XJ=jMSzYWA4RYFOEym}r96aXXSu2l_L`d&0LbdlGrKmzFFIEVOgg`_PET z=V*o}17(RPGGwA<_icysbwZHx@vEc%eG|jJMR#_1%lq@#B+*sv{{DdWF*F?EY?8oZ zi-zDu?u95_^sY@B4&CzTO{sCa+v?Chuf zX}jOTgsl_~Aif2#y)h3LoXO>Kx^y}Lx6b!su}%Nulet9xvmK-6y>y_ULjiQteZ#;nyywRMuZIbwH8(SzHK5}(f@qm(Tn$>+AhfbW+YV6ajQY+f#fy)Ra zM{%IHwFG@>#_r-l!W<=JcghS2u>#&6#sshDFYI=!dH8r`nO8fOuo>2SoeM ziwKK|?1rT_roZ}y2jFxuxB-vS2&k^OA#E~ znYsG$wwbQqsvdE^=IXJYp9!QK6MwqmROb7$zmKh_{4e)#5&vzRt5`O?fVa3J(NA?l2mo15ZO#(3cJi`@ZIZYN2$CvLxYFsmPyY+Y`)1x(_Gws zQPA!9iB10?q&)U~;hh-%elLofM~C>Nbp|$~%b6KZ|34H&PAC0hd}D?B1ADkR6mRI~ zBYZ1y+mayo?Btl-&D|aGexO^tItUCAkG8ub=&+~z%V&Oan2*f8%a9Be?oaYXgGg$d zqukZKm`W%BCG0S0ex5tqLiz1y?mShAigc+`lox1wi-A2;&k87D-12b-V>$cvxJ;PBeN_IU|B&280kmPh-T_`%JsWWwJTzzSY8w4Sd5$JJnM`R8^6%eR} zV$2l4Yw78CRK+{R3o^ApV7X&;d45uw=vwDYscJHm{qc2=;O+Mq31#Jufycbp?!LJX z&c_a-mpf6gs%U`MbZ}SAVxq{f6;lh#90FW>8W$jm2i1_XWZ_$v7RC?DneH7!waN<1#A+_bG&tdmJ|7hxf-_D%2SI~3c9(7$0~{*y4h)KCnd&jG}o`! zc(H)jiqHKXhqXW=*2)^1_592qgzY7Jh~%@H{ILYFTcjB81O@MUirPKl=2o{=pbCO1(Q=Ki#^PA@8qd3nV?RtP6KmK zXud|NiT+6i{?tfos|4G(oF-`g!=4X8yvsl>Fgp~ewqNHIKvmFpmV%yxrG*7}%_*7d zZMdC&KOyl#M8!z@MC%cccJujv4iE>}CmgrDeMg6s&^cr3J39f^3OP#s-(E22VkNX- z=)^qH-vD|eru4a0S7r8)1@0%QQX+nb$<|iK&X_Ce0pGP%lrNe!E<=)f4Z{X-Xwvb; zhxzscW}V7v8$y+n;f&6WUOC`@J-}%+cu3|l=;@M1MgHbGqbg&CHI|K{Vuxum@6Vw$3qN` zUA#ApxTTsRgHia@^5Y?HkdVDlFdt&8?}h$V;!$hVz5a&<0&eHNB4JLMU*LA7 z9N+z;=XrTi*Qb=xsb2o3thP~x!UaSOQJt|}gk1iOKW}j=ifW5rfF&-W^0CG#v%$4w zAROz%Yi53yr|BTo8h*ML=w&l^YJZIeqMYY-?uSjEE#Tud$DnV(mmxzo{AqSidkhQZ zhvJoPy`LmHwHw}rN}ZMHa4-h0Y_V$B!#FnWho6gF-b!1fEjR2?TBY%8=_^Z21C7P_ z3~(tFwMK@9Omx#>5N-%YDMmi-<5V2urw$e~HCoYcfR3kKnr63ponnRe3-hY+E+DmD zj`r4L&ZIjdmzz$Rfl*slRt*4si@%)+1j}pLME`nUF0ws#d5}^+778VdmyrlM7duSd zm6C(n!0BX9B^r7%oHhxyjEu!-1bjP?1|I%ME)vTZ^q+gSEJg94{lp1L_b2%h0Tj|9?lKLHTFz3-nx2S zuK19uJC;&HT|Fi;68C0~Ci-n@To2z-EJsIl=XUd;@cLOmk9`56>*~NGN_w8xolfTr zQ47%>p^fxpWMuQTv&qiXoMwYJJW2Rn&cQ#j;St{Q_ZC5+D6@tje7s%|3J_uFEV>;3 z4&8Hv2C@Ccry!@)=n+o>w!x|$lpu7^WikT1pW44)^QarTOJ#jnk z1v2o9ch5)2e$r*3CdX_p-zAfsT?^I;K zWi0{NEvNOCSkR6f}+U! zzT-ax0Ha{LT)I$&_Dx@|X?WL{@^8|S*%q<=>U%A%D;=+Bj2{5);fS#j6t>bnr&L5a}yB@BUL8+jYrH#D8 z7iG6OPM|R77yb>6kaJX;A2~f|U8z+jIw&aU_4BJ_TPBZTP&t6jeRH}dCk8e2KgWJw zs=G*cyyb$4cpPAE0Il--01a-}f|GI-gs8&wS85lfs1!EmeC@-Tw00%6eDz0iNDt64 z9ujeXoq_FVo~MBcDmI{L3r5sU&CHN0Jno|2OEPRZFMz|Lv;Y;XUS4;>6cb3oCaZ{Q zZN}nq048}JN;Im~0=Nc{Feth;lZBn7$JL0&=0mj=?KPJ%XKtN3_!s!nuDmN;K{2syRJ~pB@)*lLJmkr%S|JhuszIP zD!Gbv-5B+QAoiusSVO>UOjZ2>Kp<5!b-X>&nf+G3d;g;8M7gCf84u_O35N?bN*peL z^#F2k22}=taCXHFl}&4b|0h?Y#L2Y|da<$p*~mmiXQ-gZOLEDe{i86VcWXw?Ua}*J zIV9ruRIPi4-jxuXqKSD7enhYXLA9!+Bp|i%M6F;F0;pldGBUy`4<#pt6FG#Q@}i&w z5b@-usdKxZZa_oh^w0VLP==)B;I7sL7)U3<00`R2n`?gl{0VdxU&NKp(8keGb$>c< zQ|QIUy?XM?+>o+4_{+pTtmgB*b2p@soBlHsBw!VF8|vvPkQ#^r8j5D6QSx8EM1(bo zpiMZI5??Ssl3_#hk~ok|c^%hisrh}|n^_qjxWe2o%!sME8~XZOmeXO7oA>R-A&DN& zwpdJ_c#s*UYhE*^lG1?i9Te#z!27C-^LLnRz;Z}%rCOk?BF(L;I=1V(HWc*bJHSEH z#ZVkb|IKZOW9})!EpL`S7}2`7I;Ax=c5PlSPDc-jVM@tqK2Bvm%ovskhYikG9inu8 zvD`NPY5qv6G60q=3HX(-Wmlua=_!ClAtJwU@8&JjTj@=i#T^ZoEPM*V=`tfYtDS?-#o9I!hr410iQW1> z#tnqmQlditcwheKpYi%x|KEC+hFjjya{H?r2siR?HJEe%eO-!5wOv65%+-~+&^HRm gEV+CAj^DbGYd-lrqw-DVb&83Un5<~N@XHVX3tDu0vH$=8 diff --git a/ergon_paper_overleaf_edit/main.pdf b/ergon_paper_overleaf_edit/main.pdf deleted file mode 100644 index be6ea0d2b6e5eb687314198d91260fb4f33a9538..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1025554 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

{/* Floating controls — bottom-left */} diff --git a/ergon-dashboard/src/components/dag/TaskNode.tsx b/ergon-dashboard/src/components/dag/TaskNode.tsx index dfe589c8..1be4c2f1 100644 --- a/ergon-dashboard/src/components/dag/TaskNode.tsx +++ b/ergon-dashboard/src/components/dag/TaskNode.tsx @@ -10,6 +10,7 @@ import { memo } from "react"; import { type Node, type NodeProps } from "@xyflow/react"; import type { TaskState } from "@/lib/types"; +import type { EvaluationRollup } from "@/features/evaluation/contracts"; import { useGraphExpansion } from "@/features/graph/hooks/useGraphExpansion"; import { getNodeVariant } from "@/features/graph/layout/layoutTypes"; import { ContainerNode } from "@/features/graph/components/ContainerNode"; @@ -27,6 +28,8 @@ export type TaskNodeData = { maxGraphDepth?: number; /** Dagre rank direction used for this layout pass (drives handle positions). */ graphLayoutDirection?: "TB" | "LR"; + evaluationRollup?: EvaluationRollup | null; + evaluationLensActive?: boolean; }; export type TaskNodeType = Node; @@ -41,6 +44,8 @@ function TaskNodeComponent({ data }: NodeProps) { isNew = false, maxGraphDepth, graphLayoutDirection = "LR", + evaluationRollup = null, + evaluationLensActive = false, } = data; const { expandedContainers, toggleExpand, containerDimensions } = useGraphExpansion(); @@ -71,6 +76,8 @@ function TaskNodeComponent({ data }: NodeProps) { containerHeight={dims?.height ?? 100} layoutDirection={graphLayoutDirection} maxGraphDepth={maxGraphDepth} + evaluationRollup={evaluationRollup} + evaluationLensActive={evaluationLensActive} />
); @@ -89,6 +96,8 @@ function TaskNodeComponent({ data }: NodeProps) { highlighted={highlighted} layoutDirection={graphLayoutDirection} maxGraphDepth={maxGraphDepth} + evaluationRollup={evaluationRollup} + evaluationLensActive={evaluationLensActive} />
); diff --git a/ergon-dashboard/src/components/panels/EvaluationPanel.tsx b/ergon-dashboard/src/components/panels/EvaluationPanel.tsx index 66f90111..4e7fe400 100644 --- a/ergon-dashboard/src/components/panels/EvaluationPanel.tsx +++ b/ergon-dashboard/src/components/panels/EvaluationPanel.tsx @@ -6,6 +6,21 @@ function formatPercent(score: number): string { return `${(score * 100).toFixed(1)}%`; } +function statusBadgeClass(status: string): string { + switch (status) { + case "passed": + return "bg-emerald-50 text-emerald-700 ring-emerald-200"; + case "failed": + return "bg-rose-50 text-rose-700 ring-rose-200"; + case "errored": + return "bg-amber-50 text-amber-700 ring-amber-200"; + case "skipped": + return "bg-slate-100 text-slate-600 ring-slate-200"; + default: + return "bg-gray-100 text-gray-700 ring-gray-200"; + } +} + function EvaluationCriteriaEmpty({ detail }: { detail: string }) { return (
+
+
Evaluator
+
+ {evaluation.evaluatorName} +
+
+
+
Aggregation
+
+ {evaluation.aggregationRule} +
+
Normalized
@@ -72,22 +99,78 @@ export function EvaluationPanel({ >
-
- {criterion.stageName}: {criterion.criterionDescription} +
+ + {criterion.status} + +
+ {criterion.stageName}: {criterion.criterionDescription} +
- {criterion.criterionType} + {criterion.criterionName} · {criterion.criterionType} · weight {criterion.weight}
{criterion.score} / {criterion.maxScore} +
+ contribution {criterion.contribution} +
+ {criterion.modelReasoning ? ( +
+
+ Reasoning +
+

{criterion.modelReasoning}

+
+ ) : null} + {criterion.skippedReason ? ( +
+ Skipped: {criterion.skippedReason} +
+ ) : null} + {criterion.error ? ( +
+                  {JSON.stringify(criterion.error, null, 2)}
+                
+ ) : null} {criterion.feedback ? (

{criterion.feedback}

) : null} + {criterion.evaluationInput ? ( +
+ + Evaluation input + +
+                    {criterion.evaluationInput}
+                  
+
+ ) : null} + {(criterion.evaluatedActionIds.length > 0 || criterion.evaluatedResourceIds.length > 0) && ( +
+ {criterion.evaluatedActionIds.map((id) => ( + + action {id} + + ))} + {criterion.evaluatedResourceIds.map((id) => ( + + resource {id} + + ))} +
+ )}
))}
diff --git a/ergon-dashboard/src/features/evaluation/contracts.ts b/ergon-dashboard/src/features/evaluation/contracts.ts new file mode 100644 index 00000000..628de16d --- /dev/null +++ b/ergon-dashboard/src/features/evaluation/contracts.ts @@ -0,0 +1,19 @@ +export type EvalCriterionStatus = "passed" | "failed" | "errored" | "skipped"; + +export type EvalRollupStatus = "passing" | "failing" | "errored" | "skipped" | "mixed"; + +export type RubricStatusSummaryStatus = EvalRollupStatus | "none"; + +export interface EvaluationRollup { + status: EvalRollupStatus; + totalCriteria: number; + passed: number; + failed: number; + errored: number; + skipped: number; + normalizedScore: number; + maxScore: number; + evaluatorNames: string[]; + attachedTaskIds: string[]; + criterionStatuses: EvalCriterionStatus[]; +} diff --git a/ergon-dashboard/src/features/evaluation/selectors.test.ts b/ergon-dashboard/src/features/evaluation/selectors.test.ts new file mode 100644 index 00000000..c192c589 --- /dev/null +++ b/ergon-dashboard/src/features/evaluation/selectors.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { TaskEvaluationState, TaskState, WorkflowRunState } from "@/lib/types"; +import { TaskStatus } from "@/lib/types"; +import { + buildContainerEvaluationRollup, + combineEvaluationStatuses, + evaluationToRollup, + isEvaluationBearingTask, +} from "./selectors"; + +function task(id: string, childIds: string[] = []): TaskState { + return { + id, + name: id, + description: id, + status: TaskStatus.COMPLETED, + parentId: null, + childIds, + dependsOnIds: [], + isLeaf: childIds.length === 0, + level: 0, + assignedWorkerId: null, + assignedWorkerName: null, + startedAt: null, + completedAt: null, + history: [], + lastTrigger: null, + }; +} + +function evaluation(taskId: string, statuses: Array<"passed" | "failed" | "errored" | "skipped">): TaskEvaluationState { + return { + id: `evaluation-${taskId}`, + runId: "run-1", + taskId, + evaluatorName: "rubric", + aggregationRule: "weighted_sum", + totalScore: statuses.filter((status) => status === "passed").length, + maxScore: statuses.length, + normalizedScore: statuses.length > 0 ? statuses.filter((status) => status === "passed").length / statuses.length : 0, + stagesEvaluated: 1, + stagesPassed: statuses.every((status) => status === "passed") ? 1 : 0, + failedGate: null, + createdAt: "2026-04-27T12:00:00.000Z", + criterionResults: statuses.map((status, index) => ({ + id: `${taskId}-${index}`, + stageNum: 0, + stageName: "default", + criterionNum: index, + criterionType: "fixture", + criterionDescription: `${status} criterion`, + criterionName: `${status} criterion`, + status, + passed: status === "passed", + weight: 1, + contribution: status === "passed" ? 1 : 0, + score: status === "passed" ? 1 : 0, + maxScore: 1, + feedback: null, + modelReasoning: null, + skippedReason: null, + evaluationInput: null, + error: status === "errored" ? { kind: "fixture" } : null, + evaluatedActionIds: [], + evaluatedResourceIds: [], + })), + }; +} + +function state(evaluationsByTask: Map): WorkflowRunState { + return { + id: "run-1", + experimentId: "experiment-1", + name: "run", + status: "completed", + tasks: new Map([ + ["root", task("root", ["child-a", "child-b"])], + ["child-a", task("child-a")], + ["child-b", task("child-b")], + ]), + rootTaskId: "root", + resourcesByTask: new Map(), + executionsByTask: new Map(), + evaluationsByTask, + sandboxesByTask: new Map(), + threads: [], + contextEventsByTask: new Map(), + startedAt: "2026-04-27T12:00:00.000Z", + completedAt: null, + durationSeconds: null, + totalTasks: 3, + totalLeafTasks: 2, + completedTasks: 3, + failedTasks: 0, + runningTasks: 0, + cancelledTasks: 0, + finalScore: null, + error: null, + edges: new Map(), + annotationsByTarget: new Map(), + unhandledMutations: [], + }; +} + +test("evaluationToRollup returns null when there are no criteria", () => { + assert.equal(evaluationToRollup(evaluation("child-a", [])), null); +}); + +test("evaluationToRollup preserves explicit failed, skipped, and errored states", () => { + const rollup = evaluationToRollup(evaluation("child-a", ["passed", "failed", "skipped"])); + + assert.equal(rollup?.status, "failing"); + assert.equal(rollup?.passed, 1); + assert.equal(rollup?.failed, 1); + assert.equal(rollup?.skipped, 1); + assert.deepEqual(rollup?.criterionStatuses, ["passed", "failed", "skipped"]); + + assert.equal(evaluationToRollup(evaluation("child-a", ["errored"]))?.status, "errored"); +}); + +test("container rollup aggregates descendants and returns null for no evidence", () => { + const empty = state(new Map()); + assert.equal(buildContainerEvaluationRollup(empty, "root"), null); + assert.equal(isEvaluationBearingTask(empty, "root"), false); + + const populated = state( + new Map([ + ["child-a", evaluation("child-a", ["passed", "skipped"])], + ["child-b", evaluation("child-b", ["passed"])], + ]), + ); + + const rollup = buildContainerEvaluationRollup(populated, "root"); + + assert.equal(rollup?.status, "mixed"); + assert.equal(rollup?.totalCriteria, 3); + assert.equal(rollup?.passed, 2); + assert.equal(rollup?.skipped, 1); + assert.deepEqual(rollup?.attachedTaskIds, ["child-a", "child-b"]); + assert.equal(isEvaluationBearingTask(populated, "root"), true); +}); + +test("combineEvaluationStatuses prioritizes errored then failing before mixed", () => { + assert.equal(combineEvaluationStatuses(["passing", "errored", "failing"]), "errored"); + assert.equal(combineEvaluationStatuses(["passing", "failing", "mixed"]), "failing"); + assert.equal(combineEvaluationStatuses(["passing", "skipped"]), "mixed"); + assert.equal(combineEvaluationStatuses(["skipped", "skipped"]), "skipped"); +}); diff --git a/ergon-dashboard/src/features/evaluation/selectors.ts b/ergon-dashboard/src/features/evaluation/selectors.ts new file mode 100644 index 00000000..818abc91 --- /dev/null +++ b/ergon-dashboard/src/features/evaluation/selectors.ts @@ -0,0 +1,84 @@ +import type { TaskEvaluationState, WorkflowRunState } from "@/lib/types"; +import type { EvalCriterionStatus, EvalRollupStatus, EvaluationRollup } from "./contracts"; + +function criterionStatusToRollupStatus(status: EvalCriterionStatus): EvalRollupStatus { + if (status === "passed") return "passing"; + if (status === "failed") return "failing"; + return status; +} + +export function combineEvaluationStatuses(statuses: EvalRollupStatus[]): EvalRollupStatus { + if (statuses.includes("errored")) return "errored"; + if (statuses.includes("failing")) return "failing"; + if (statuses.includes("mixed")) return "mixed"; + if (statuses.includes("skipped") && statuses.includes("passing")) return "mixed"; + if (statuses.every((status) => status === "skipped")) return "skipped"; + return "passing"; +} + +export function evaluationToRollup(evaluation: TaskEvaluationState | undefined): EvaluationRollup | null { + if (!evaluation || evaluation.criterionResults.length === 0) return null; + + const criterionStatuses = evaluation.criterionResults.map( + (criterion) => criterion.status as EvalCriterionStatus, + ); + const passed = criterionStatuses.filter((status) => status === "passed").length; + const failed = criterionStatuses.filter((status) => status === "failed").length; + const errored = criterionStatuses.filter((status) => status === "errored").length; + const skipped = criterionStatuses.filter((status) => status === "skipped").length; + + return { + status: combineEvaluationStatuses(criterionStatuses.map(criterionStatusToRollupStatus)), + totalCriteria: criterionStatuses.length, + passed, + failed, + errored, + skipped, + normalizedScore: evaluation.normalizedScore, + maxScore: evaluation.maxScore, + evaluatorNames: [evaluation.evaluatorName], + attachedTaskIds: evaluation.taskId ? [evaluation.taskId] : [], + criterionStatuses, + }; +} + +export function buildContainerEvaluationRollup( + state: WorkflowRunState, + taskId: string, +): EvaluationRollup | null { + const task = state.tasks.get(taskId); + if (!task) return null; + + const direct = evaluationToRollup(state.evaluationsByTask.get(taskId)); + const childRollups = task.childIds.map((childId) => buildContainerEvaluationRollup(state, childId)); + const rollups = [direct, ...childRollups].filter( + (rollup): rollup is EvaluationRollup => rollup !== null, + ); + + if (rollups.length === 0) return null; + + const totalCriteria = rollups.reduce((sum, rollup) => sum + rollup.totalCriteria, 0); + const maxScore = rollups.reduce((sum, rollup) => sum + rollup.maxScore, 0); + const weightedScore = rollups.reduce( + (sum, rollup) => sum + rollup.normalizedScore * rollup.maxScore, + 0, + ); + + return { + status: combineEvaluationStatuses(rollups.map((rollup) => rollup.status)), + totalCriteria, + passed: rollups.reduce((sum, rollup) => sum + rollup.passed, 0), + failed: rollups.reduce((sum, rollup) => sum + rollup.failed, 0), + errored: rollups.reduce((sum, rollup) => sum + rollup.errored, 0), + skipped: rollups.reduce((sum, rollup) => sum + rollup.skipped, 0), + normalizedScore: maxScore > 0 ? weightedScore / maxScore : 0, + maxScore, + evaluatorNames: Array.from(new Set(rollups.flatMap((rollup) => rollup.evaluatorNames))).sort(), + attachedTaskIds: Array.from(new Set(rollups.flatMap((rollup) => rollup.attachedTaskIds))).sort(), + criterionStatuses: rollups.flatMap((rollup) => rollup.criterionStatuses), + }; +} + +export function isEvaluationBearingTask(state: WorkflowRunState, taskId: string): boolean { + return buildContainerEvaluationRollup(state, taskId) !== null; +} diff --git a/ergon-dashboard/src/features/graph/components/ContainerNode.tsx b/ergon-dashboard/src/features/graph/components/ContainerNode.tsx index e0c6b5f4..5a78e40c 100644 --- a/ergon-dashboard/src/features/graph/components/ContainerNode.tsx +++ b/ergon-dashboard/src/features/graph/components/ContainerNode.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import { Handle, Position } from "@xyflow/react"; import type { TaskState, TaskStatus } from "@/lib/types"; +import type { EvaluationRollup } from "@/features/evaluation/contracts"; interface ContainerNodeProps { task: TaskState; @@ -16,6 +17,8 @@ interface ContainerNodeProps { containerHeight: number; layoutDirection?: "TB" | "LR"; maxGraphDepth?: number; + evaluationRollup?: EvaluationRollup | null; + evaluationLensActive?: boolean; } function ContainerNodeComponent(props: ContainerNodeProps) { @@ -30,6 +33,7 @@ function ContainerNodeComponent(props: ContainerNodeProps) { containerWidth, containerHeight, layoutDirection = "LR", + evaluationRollup = null, } = props; const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -98,6 +102,15 @@ function ContainerNodeComponent(props: ContainerNodeProps) { > {task.childIds.length} subtask{task.childIds.length !== 1 ? "s" : ""} + {evaluationRollup && ( + + R + + )}
)} -
+
{selectionNotice && (
)} -
0 ? 300 : 0, - paddingRight: isInspectorOpen ? 476 : 0, + 0 ? "with-timeline" : "without-timeline" + }`} + orientation="vertical" + defaultLayout={activities.length > 0 ? verticalLayout : { "graph-workspace": 100 }} + onLayoutChange={(layout) => { + if (activities.length > 0) { + setVerticalLayout(layout); + savePanelLayout(VERTICAL_LAYOUT_STORAGE_KEY, layout); + } }} + className="size-full" > - -
- - {activities.length > 0 && ( -
- -
- )} - - {isStreamOpen && events.length > 0 && ( -
- { - setSelectionNotice(null); - setSelectedTaskId(id); - }} - onSequenceClick={(seq) => { - requestedSequenceRef.current = seq; - handleSequenceChange(seq); - }} - /> -
- )} - - {isInspectorOpen ? ( -
0 + ? panelPercent(verticalLayout, "graph-workspace", 62) + : "100%" + } + minSize="28%" > - setSelectedTaskId(null)} - onJumpToSequence={(seq) => { - requestedSequenceRef.current = seq; - handleSequenceChange(seq); + { + if (isInspectorOpen) { + setHorizontalLayout(layout); + savePanelLayout(HORIZONTAL_LAYOUT_STORAGE_KEY, layout); + } }} - selectedTime={selectedTimelineTime} - selectedSequence={snapshotSequence} - selectedActivity={selectedActivity} - /> -
- ) : ( -
-
-
- Task inspection -
-

- Click node → workspace drawer -

-

State, outputs, turns, and evals appear scoped to the selected sequence.

- {selectedTask && ( -
- Ready to inspect {selectedTask.name}. -
+ className="size-full" + > + +
+ + + {isStreamOpen && events.length > 0 && ( +
+ { + setSelectionNotice(null); + setSelectedTaskId(id); + }} + onSequenceClick={(seq) => { + requestedSequenceRef.current = seq; + handleSequenceChange(seq); + }} + /> +
+ )} + + {!isInspectorOpen && ( +
+
+
+ Task inspection +
+

+ Click node → workspace drawer +

+

State, outputs, turns, and evals appear scoped to the selected sequence.

+ {selectedTask && ( +
+ Ready to inspect {selectedTask.name}. +
+ )} +
+
+ )} +
+
+ + {isInspectorOpen && ( + <> + +
+ + +
+ setSelectedTaskId(null)} + onJumpToSequence={(seq) => { + requestedSequenceRef.current = seq; + handleSequenceChange(seq); + }} + selectedTime={selectedTimelineTime} + selectedSequence={snapshotSequence} + selectedActivity={selectedActivity} + /> +
+
+ )} -
-
- )} + + + + {activities.length > 0 && ( + <> + +
+ + +
+ +
+
+ + )} +
); diff --git a/ergon-dashboard/tests/e2e/run.snapshot.spec.ts b/ergon-dashboard/tests/e2e/run.snapshot.spec.ts index f0ef092a..4eac6fcc 100644 --- a/ergon-dashboard/tests/e2e/run.snapshot.spec.ts +++ b/ergon-dashboard/tests/e2e/run.snapshot.spec.ts @@ -204,3 +204,65 @@ test("persisted run snapshot remains inspectable after refresh", async ({ page } await page.getByTestId("workspace-tab-actions").click(); await expect(page.getByTestId("workspace-executions")).toContainText("Attempt 1"); }); + +test("run debugger panels can be resized and persist across reloads", async ({ page }) => { + await page.goto(`/cohorts/${FIXTURE_IDS.cohortId}/runs/${FIXTURE_IDS.runId}`); + + await expect(page.getByTestId("graph-canvas")).toBeVisible(); + await expect(page.getByTestId("timeline-region")).toBeVisible(); + + const timelineBefore = await page.getByTestId("timeline-region").boundingBox(); + const timelineHandle = page.getByTestId("timeline-resize-handle"); + const timelineHandleBox = await timelineHandle.boundingBox(); + expect(timelineBefore).not.toBeNull(); + expect(timelineHandleBox).not.toBeNull(); + + await page.mouse.move(timelineHandleBox!.x + timelineHandleBox!.width / 2, timelineHandleBox!.y + 2); + await page.mouse.down(); + await page.mouse.move(timelineHandleBox!.x + timelineHandleBox!.width / 2, timelineHandleBox!.y - 90); + await page.mouse.up(); + + await expect + .poll(async () => (await page.getByTestId("timeline-region").boundingBox())?.height ?? 0) + .toBeGreaterThan(timelineBefore!.height + 40); + const savedVerticalLayout = await page.evaluate(() => + window.localStorage.getItem("ergon-run-debugger-vertical-layout:v1"), + ); + expect(savedVerticalLayout).not.toBeNull(); + expect(JSON.parse(savedVerticalLayout!).timeline).toBeGreaterThan(38); + + await page.getByTestId(`graph-node-${FIXTURE_IDS.solveTaskId}`).click(); + await expect(page.getByTestId("workspace-region")).toBeVisible(); + + const workspaceBefore = await page.getByTestId("workspace-region").boundingBox(); + const workspaceHandle = page.getByTestId("workspace-resize-handle"); + const workspaceHandleBox = await workspaceHandle.boundingBox(); + expect(workspaceBefore).not.toBeNull(); + expect(workspaceHandleBox).not.toBeNull(); + + await page.mouse.move(workspaceHandleBox!.x + 2, workspaceHandleBox!.y + workspaceHandleBox!.height / 2); + await page.mouse.down(); + await page.mouse.move(workspaceHandleBox!.x - 90, workspaceHandleBox!.y + workspaceHandleBox!.height / 2); + await page.mouse.up(); + + await expect + .poll(async () => (await page.getByTestId("workspace-region").boundingBox())?.width ?? 0) + .toBeGreaterThan(workspaceBefore!.width + 40); + + const timelineAfterDrag = await page.getByTestId("timeline-region").boundingBox(); + const workspaceAfterDrag = await page.getByTestId("workspace-region").boundingBox(); + expect(timelineAfterDrag).not.toBeNull(); + expect(workspaceAfterDrag).not.toBeNull(); + + await page.reload(); + await expect(page.getByTestId("graph-canvas")).toBeVisible(); + await page.getByTestId(`graph-node-${FIXTURE_IDS.solveTaskId}`).click(); + await expect(page.getByTestId("workspace-region")).toBeVisible(); + + await expect + .poll(async () => (await page.getByTestId("timeline-region").boundingBox())?.height ?? 0) + .toBeGreaterThan(timelineBefore!.height + 40); + await expect + .poll(async () => (await page.getByTestId("workspace-region").boundingBox())?.width ?? 0) + .toBeGreaterThan(workspaceBefore!.width + 40); +}); diff --git a/ergon_builtins/ergon_builtins/models/openrouter_backend.py b/ergon_builtins/ergon_builtins/models/openrouter_backend.py new file mode 100644 index 00000000..5ec1d3e5 --- /dev/null +++ b/ergon_builtins/ergon_builtins/models/openrouter_backend.py @@ -0,0 +1,24 @@ +"""OpenRouter backend using PydanticAI's OpenRouter provider.""" + +import logging + +from ergon_core.core.providers.generation.model_resolution import ResolvedModel +from ergon_core.core.settings import settings +from pydantic_ai.models.openrouter import OpenRouterModel, OpenRouterProvider + +logger = logging.getLogger(__name__) + + +def resolve_openrouter( + target: str, + *, + model_name: str | None = None, + policy_version: str | None = None, + api_key: str | None = None, +) -> ResolvedModel: + """Resolve ``openrouter:model-id`` to an OpenRouter-backed chat model.""" + resolved_name = model_name or target.removeprefix("openrouter:") + provider = OpenRouterProvider(api_key=api_key or settings.openrouter_api_key) + model = OpenRouterModel(model_name=resolved_name, provider=provider) + logger.info("Resolved OpenRouter model: model_name=%s", resolved_name) + return ResolvedModel(model=model, policy_version=policy_version, supports_logprobs=False) diff --git a/ergon_builtins/ergon_builtins/registry_core.py b/ergon_builtins/ergon_builtins/registry_core.py index 7be86868..4d1f98d9 100644 --- a/ergon_builtins/ergon_builtins/registry_core.py +++ b/ergon_builtins/ergon_builtins/registry_core.py @@ -26,6 +26,7 @@ from ergon_builtins.benchmarks.swebench_verified.toolkit import SWEBenchToolkit from ergon_builtins.evaluators.rubrics.swebench_rubric import SWEBenchRubric from ergon_builtins.models.cloud_passthrough import resolve_cloud +from ergon_builtins.models.openrouter_backend import resolve_openrouter from ergon_builtins.models.vllm_backend import resolve_vllm from ergon_builtins.workers.baselines.react_prompts import ( MINIF2F_SYSTEM_PROMPT, @@ -184,4 +185,5 @@ def _swebench_react( "openai": resolve_cloud, "anthropic": resolve_cloud, "google": resolve_cloud, + "openrouter": resolve_openrouter, } diff --git a/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py b/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py index 0b540b6c..6195f414 100644 --- a/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py +++ b/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py @@ -1,3 +1,4 @@ +import asyncio from collections.abc import Awaitable, Callable from typing import Protocol from uuid import UUID @@ -45,7 +46,8 @@ async def workflow(command: str) -> str: if worker_context.node_id is None: raise ValueError("workflow tool requires WorkerContext.node_id") - output = execute_command( + output = await asyncio.to_thread( + execute_command, command, context=WorkflowCommandContext( run_id=worker_context.run_id, diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py index b03334db..d766ee39 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py @@ -52,7 +52,10 @@ "- read_report_draft: Read a draft file\n" "- Resource discovery tools to observe peer outputs\n\n" "Write your final report to 'final_output/report.md' using write_report_draft. " - "Include a # Findings section and a ## Sources section with citations." + "Include a # Findings section and a ## Sources section with citations. " + "For scoped child tasks, keep the evidence pass bounded: use at most six " + "search/QA/content tool calls total, then write a concise report and call " + "final_result. Do not keep searching indefinitely." ) @@ -89,7 +92,7 @@ def __init__( sandbox_id=sandbox_id, tools=[], system_prompt=_RESEARCHER_SYSTEM_PROMPT, - max_iterations=25, + max_iterations=60, ) async def execute( diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py index 302a9bde..648b29ce 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py @@ -42,7 +42,7 @@ "- write_report_draft: Write a markdown report draft\n" "- edit_report_draft: Edit an existing draft\n" "- read_report_draft: Read a draft file\n" - "- workflow: Inspect current-run task topology and resources\n\n" + "- workflow: Inspect current-run task topology/resources and manage subtasks\n\n" "Write your final report to 'final_output/report.md' using write_report_draft. " "Include a # Findings section and a ## Sources section with citations.\n\n" "Use workflow(command) to inspect this run before " @@ -51,6 +51,18 @@ "`inspect resource-list --scope visible --limit 20`, " "`inspect next-actions`, and " "`manage materialize-resource --resource-id --dry-run`. " + "For ResearchRubrics benchmark tasks, start by creating at least one real " + "child research subtask unless the request is truly trivial. First dry-run " + "the shape, then create focused children with commands like: " + "`manage add-task --task-slug source-scout --worker researchrubrics-researcher " + "--description 'Find high-quality sources for ...' --dry-run`, then repeat " + "without `--dry-run` once the command is correct. Use worker " + "`researchrubrics-researcher` for child research tasks, and use " + "`researchrubrics-workflow-cli-react` only when a child should itself be " + "manager-capable. After creating children, do not duplicate their research " + "yourself; use `inspect task-tree --wait-seconds 60` until children are terminal, then inspect " + "`resource-list --scope children` and use their reports as evidence before " + "composing the final report. " "Use `--format json` when you need stable IDs. Resource copies are snapshots: " "materialized files become resources owned by this task, not edits to the source." ) @@ -81,7 +93,7 @@ def __init__( sandbox_id=sandbox_id, tools=[], system_prompt=_WORKFLOW_PROMPT, - max_iterations=25, + max_iterations=60, ) async def execute( diff --git a/ergon_cli/ergon_cli/commands/workflow.py b/ergon_cli/ergon_cli/commands/workflow.py index 27a32d9f..93286a11 100644 --- a/ergon_cli/ergon_cli/commands/workflow.py +++ b/ergon_cli/ergon_cli/commands/workflow.py @@ -1,7 +1,10 @@ import argparse import asyncio +import contextlib +import io import json import shlex +import time from typing import cast from uuid import UUID @@ -62,6 +65,7 @@ def build_workflow_parser() -> argparse.ArgumentParser: task_tree = inspect_sub.add_parser("task-tree") task_tree.add_argument("--format", choices=["text", "json"], default="text") task_tree.add_argument("--parent-node-id", default=None) + task_tree.add_argument("--wait-seconds", type=float, default=0) dependencies = inspect_sub.add_parser("task-dependencies") dependencies.add_argument( @@ -86,6 +90,11 @@ def build_workflow_parser() -> argparse.ArgumentParser: parser_for_action.add_argument("--dry-run", action="store_true") parser_for_action.add_argument("--format", choices=["text", "json"], default="text") parser_for_action.add_argument("--reason", default=None) + if action == "add-task": + parser_for_action.add_argument("--task-slug", required=True) + parser_for_action.add_argument("--description", required=True) + parser_for_action.add_argument("--worker", required=True) + parser_for_action.add_argument("--depends-on-task-slug", action="append", default=[]) return parser @@ -97,9 +106,18 @@ def execute_workflow_command( session_factory: Callable[[], Session], service: WorkflowService, ) -> WorkflowCommandOutput: - argv = shlex.split(command) + try: + argv = shlex.split(command) + except ValueError as exc: + return WorkflowCommandOutput(stdout="", stderr=str(exc), exit_code=2) _reject_context_flags(argv) - args = build_workflow_parser().parse_args(argv) + stderr = io.StringIO() + try: + with contextlib.redirect_stderr(stderr): + args = build_workflow_parser().parse_args(argv) + except SystemExit as exc: + exit_code = exc.code if isinstance(exc.code, int) else 2 + return WorkflowCommandOutput(stdout="", stderr=stderr.getvalue() or str(exc), exit_code=exit_code) session = session_factory() try: if args.group == "inspect": @@ -186,7 +204,14 @@ def _handle_inspect( return WorkflowCommandOutput(stdout=content.decode(errors="replace")) if args.action == "task-tree": parent = UUID(args.parent_node_id) if args.parent_node_id else None + deadline = time.monotonic() + max(args.wait_seconds, 0) tasks = service.list_tasks(session, run_id=context.run_id, parent_node_id=parent) + while args.wait_seconds > 0 and time.monotonic() < deadline: + children = [task for task in tasks if task.parent_node_id == context.node_id] + if children and all(task.status in {"completed", "failed", "cancelled"} for task in children): + break + time.sleep(2) + tasks = service.list_tasks(session, run_id=context.run_id, parent_node_id=parent) return _format_output( {"tasks": [_dump(task) for task in tasks]}, text_lines=[ @@ -249,6 +274,31 @@ async def _handle_manage( text_lines=[f"{result.source_resource_id} -> {result.sandbox_path}"], output_format=args.format, ) + if args.action == "add-task": + if args.dry_run: + payload: JsonObject = { + "action": args.action, + "dry_run": True, + "task_slug": args.task_slug, + "assigned_worker_slug": args.worker, + "depends_on_task_slugs": args.depends_on_task_slug, + "message": "Graph lifecycle command validated; no changes applied.", + } + return _format_output(payload, [str(payload["message"])], args.format) + result = await service.add_task( + session, + run_id=context.run_id, + parent_node_id=context.node_id, + task_slug=args.task_slug, + description=args.description, + assigned_worker_slug=args.worker, + depends_on_task_slugs=args.depends_on_task_slug, + ) + return _format_output( + {"task": _dump(result)}, + text_lines=[f"{result.task_slug} {result.status} {result.node_id}"], + output_format=args.format, + ) try: dry_run = args.dry_run except AttributeError: diff --git a/ergon_cli/ergon_cli/composition/__init__.py b/ergon_cli/ergon_cli/composition/__init__.py index 41952ede..b23ae59e 100644 --- a/ergon_cli/ergon_cli/composition/__init__.py +++ b/ergon_cli/ergon_cli/composition/__init__.py @@ -50,6 +50,12 @@ def build_experiment( worker_slug=worker_slug, model=model, ) + if worker_slug == "researchrubrics-workflow-cli-react": + return _build_researchrubrics_workflow_experiment( + benchmark=benchmark, + evaluator=evaluator, + model=model, + ) spec = WorkerSpec(worker_slug=worker_slug, name="worker", model=model) return Experiment.from_single_worker( @@ -139,6 +145,49 @@ def _build_smoke_experiment( ) +def _build_researchrubrics_workflow_experiment( + *, + benchmark, + evaluator, + model: str, +): + """Register CLI-manager plus child worker bindings for dynamic subtasks.""" + manager_name = "manager" + workers = { + manager_name: WorkerSpec( + worker_slug="researchrubrics-workflow-cli-react", + name=manager_name, + model=model, + ), + "researchrubrics-workflow-cli-react": WorkerSpec( + worker_slug="researchrubrics-workflow-cli-react", + name="researchrubrics-workflow-cli-react", + model=model, + ), + "researchrubrics-researcher": WorkerSpec( + worker_slug="researchrubrics-researcher", + name="researchrubrics-researcher", + model=model, + ), + } + instances = benchmark.build_instances() + all_task_slugs = [task.task_slug for tasks in instances.values() for task in tasks] + evaluators = {"default": evaluator} + if "post-root" in benchmark.evaluator_requirements(): + from ergon_core.test_support.smoke_fixtures.criteria.timing import ( + SmokePostRootTimingRubric, + ) + + evaluators["post-root"] = SmokePostRootTimingRubric(name="post-root") + + return Experiment( + benchmark=benchmark, + workers=workers, + evaluators=evaluators, + assignments={manager_name: all_task_slugs}, + ) + + def _construct_benchmark(cls, workflow: str, limit: int | None): """Try constructing with all kwargs, progressively dropping unsupported ones.""" kwargs: dict[str, str | int] = {} diff --git a/ergon_core/ergon_core/core/runtime/errors/error_payload.py b/ergon_core/ergon_core/core/runtime/errors/error_payload.py new file mode 100644 index 00000000..55833feb --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/errors/error_payload.py @@ -0,0 +1,35 @@ +"""Structured runtime error payloads for persisted execution failures.""" + +import traceback +from collections.abc import Mapping +from typing import Any + +from ergon_core.api.json_types import JsonObject +from pydantic import BaseModel, Field + + +class RuntimeErrorPayload(BaseModel): + """Persisted shape for task execution failures.""" + + message: str + exception_type: str + phase: str + stack: str + context: dict[str, str] = Field(default_factory=dict) + + +def build_error_json( + exc: BaseException, + *, + phase: str, + context: Mapping[str, Any] | None = None, +) -> JsonObject: + """Return stack-rich, queryable error details for PG persistence.""" + payload = RuntimeErrorPayload( + message=str(exc), + exception_type=type(exc).__name__, + phase=phase, + stack="".join(traceback.format_exception(type(exc), exc, exc.__traceback__)), + context={key: str(value) for key, value in (context or {}).items()}, + ) + return payload.model_dump(mode="json") diff --git a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py b/ergon_core/ergon_core/core/runtime/inngest/execute_task.py index 494ee874..78231a6e 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py +++ b/ergon_core/ergon_core/core/runtime/inngest/execute_task.py @@ -9,6 +9,7 @@ import inngest from ergon_core.core.runtime.errors import ContractViolationError +from ergon_core.core.runtime.errors.error_payload import build_error_json from ergon_core.core.runtime.events.task_events import ( TaskCompletedEvent, TaskFailedEvent, @@ -225,7 +226,24 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: if not worker_result.success: await _persist_outputs(ctx, payload, prepared, sandbox_result) - raise RuntimeError(worker_result.error or "Worker execution failed") + error_msg = worker_result.error or "Worker execution failed" + await svc.finalize_failure( + FailTaskExecutionCommand( + execution_id=prepared.execution_id, + run_id=payload.run_id, + task_id=payload.task_id, + error_message=error_msg, + error_json=worker_result.error_json, + ) + ) + await _emit_task_failed(payload, prepared, error_msg, task_sandbox_id) + return TaskExecuteResult( + run_id=payload.run_id, + task_id=payload.task_id, + execution_id=prepared.execution_id, + success=False, + error=error_msg, + ) persist_result = await _persist_outputs(ctx, payload, prepared, sandbox_result) @@ -291,6 +309,18 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: run_id=payload.run_id, task_id=payload.task_id, error_message=error_msg, + error_json=build_error_json( + exc, + phase="task_execute", + context={ + "task_slug": prepared.task_slug, + "assigned_worker_slug": prepared.assigned_worker_slug, + "worker_type": prepared.worker_type, + "model_target": prepared.model_target, + "node_id": prepared.node_id, + "execution_id": prepared.execution_id, + }, + ), ) ) diff --git a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py index e6c3a8e9..95133099 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py +++ b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py @@ -21,6 +21,7 @@ from ergon_core.core.persistence.queries import queries from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.runtime.errors import RegistryLookupError +from ergon_core.core.runtime.errors.error_payload import build_error_json from ergon_core.core.runtime.inngest_client import inngest_client from ergon_core.core.runtime.services.child_function_payloads import WorkerExecuteRequest from ergon_core.core.runtime.services.inngest_function_results import WorkerExecuteResult @@ -41,6 +42,14 @@ def _worker_execute_result_from_output(output: WorkerOutput) -> WorkerExecuteRes ) +def _worker_execute_result_from_exception(exc: BaseException) -> WorkerExecuteResult: + return WorkerExecuteResult( + success=False, + error=str(exc), + error_json=build_error_json(exc, phase="worker_execute"), + ) + + @inngest_client.create_function( fn_id="worker-execute", trigger=inngest.TriggerEvent(event="task/worker-execute"), @@ -137,7 +146,7 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: turn_count, error_msg, ) - raise + return _worker_execute_result_from_exception(exc) sink = get_trace_sink() sink.emit_span( diff --git a/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py b/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py index 6304f143..f7b5c254 100644 --- a/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py +++ b/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py @@ -6,6 +6,7 @@ from typing import Literal from uuid import UUID +from ergon_core.api.json_types import JsonObject from pydantic import BaseModel, Field @@ -71,6 +72,7 @@ class WorkerExecuteResult(BaseModel): success: bool = False final_assistant_message: str | None = None error: str | None = None + error_json: JsonObject | None = None class PersistOutputsResult(BaseModel): diff --git a/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py b/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py index 2cf2e40e..5f514809 100644 --- a/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py @@ -107,6 +107,7 @@ class FailTaskExecutionCommand(BaseModel): run_id: UUID task_id: UUID | None error_message: str + error_json: JsonObject | None = None class WorkflowTerminalState(StrEnum): diff --git a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py b/ergon_core/ergon_core/core/runtime/services/task_execution_service.py index acc13892..62e3c168 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_execution_service.py @@ -330,7 +330,7 @@ async def finalize_failure(self, command: FailTaskExecutionCommand) -> None: ) execution.status = TaskExecutionStatus.FAILED execution.completed_at = utcnow() - execution.error_json = {"message": command.error_message} + execution.error_json = command.error_json or {"message": command.error_message} session.add(execution) graph_repo = WorkflowGraphRepository() diff --git a/ergon_core/ergon_core/core/runtime/services/task_management_service.py b/ergon_core/ergon_core/core/runtime/services/task_management_service.py index f952250a..c6d72e36 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_management_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_management_service.py @@ -153,6 +153,14 @@ async def add_subtask( session.commit() + if not command.depends_on: + definition_id = self._resolve_definition_id(session, command.run_id) + await self._dispatch_task_ready( + run_id=command.run_id, + definition_id=definition_id, + node_id=node.id, + ) + logger.info( "add_subtask: created node %s (slug=%s) under parent %s", node.id, diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_service.py b/ergon_core/ergon_core/core/runtime/services/workflow_service.py index a9aaff6b..1375697c 100644 --- a/ergon_core/ergon_core/core/runtime/services/workflow_service.py +++ b/ergon_core/ergon_core/core/runtime/services/workflow_service.py @@ -4,6 +4,12 @@ from uuid import UUID from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode +from ergon_core.core.persistence.shared.types import ( + AssignedWorkerSlug, + NodeId, + RunId, + TaskSlug, +) from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import ( RunResource, @@ -19,6 +25,11 @@ WorkflowResourceRef, WorkflowTaskRef, ) +from ergon_core.core.runtime.services.task_management_dto import ( + AddSubtaskCommand, + AddSubtaskResult, +) +from ergon_core.core.runtime.services.task_management_service import TaskManagementService from sqlmodel import Session, col, select ResourceScope = Literal["input", "upstream", "own", "children", "descendants", "visible"] @@ -203,6 +214,40 @@ def get_next_actions( ) ] + async def add_task( + self, + session: Session, + *, + run_id: UUID, + parent_node_id: UUID, + task_slug: str, + description: str, + assigned_worker_slug: str, + depends_on_task_slugs: list[str], + ) -> AddSubtaskResult: + deps = [ + NodeId( + self._resolve_node( + session, + run_id=run_id, + node_id=None, + task_slug=dep_slug, + ).id + ) + for dep_slug in depends_on_task_slugs + ] + return await TaskManagementService().add_subtask( + session, + AddSubtaskCommand( + run_id=RunId(run_id), + parent_node_id=NodeId(parent_node_id), + task_slug=TaskSlug(task_slug), + description=description, + assigned_worker_slug=AssignedWorkerSlug(assigned_worker_slug), + depends_on=deps, + ), + ) + async def materialize_resource( # slopcop: ignore[max-function-params] -- mirrors CLI scope fields self, session: Session, diff --git a/ergon_core/pyproject.toml b/ergon_core/pyproject.toml index 19b4002a..d1d6d7d8 100644 --- a/ergon_core/pyproject.toml +++ b/ergon_core/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "uvicorn>=0.24.0", "e2b-code-interpreter", "openai", - "pydantic-ai", + "pydantic-ai>=1.87.0", "litellm", "opentelemetry-api", "opentelemetry-sdk", diff --git a/tests/integration/propagation/test_add_subtask_dispatch.py b/tests/integration/propagation/test_add_subtask_dispatch.py new file mode 100644 index 00000000..c23f460a --- /dev/null +++ b/tests/integration/propagation/test_add_subtask_dispatch.py @@ -0,0 +1,60 @@ +"""Integration tests for dynamically added subtask dispatch.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.persistence.shared.types import ( + AssignedWorkerSlug, + NodeId, + RunId, + TaskSlug, +) +from ergon_core.core.runtime.events.task_events import TaskReadyEvent +from ergon_core.core.runtime.services.task_management_dto import AddSubtaskCommand +from ergon_core.core.runtime.services.task_management_service import TaskManagementService + +from tests.integration.propagation._helpers import make_experiment_definition, make_node, make_run +from tests.integration.restart._helpers import cleanup_run + +pytestmark = pytest.mark.integration + +_TMS_INNGEST = "ergon_core.core.runtime.services.task_management_service.inngest_client" +_EMITTER_INNGEST = "ergon_core.core.dashboard.emitter.inngest_client" + + +@pytest.mark.asyncio +async def test_add_subtask_dispatches_dependency_free_child() -> None: + with get_session() as session: + definition = make_experiment_definition(session) + run = make_run(session, definition.id) + parent = make_node(session, run.id, task_slug="root", status="running") + run_id = run.id + definition_id = definition.id + parent_id = parent.id + session.commit() + + try: + with patch(_TMS_INNGEST) as task_mgmt_inngest, patch(_EMITTER_INNGEST) as emitter_inngest: + task_mgmt_inngest.send = AsyncMock() + emitter_inngest.send = AsyncMock() + with get_session() as session: + result = await TaskManagementService().add_subtask( + session, + AddSubtaskCommand( + run_id=RunId(run_id), + parent_node_id=NodeId(parent_id), + task_slug=TaskSlug("source-scout"), + description="Find sources.", + assigned_worker_slug=AssignedWorkerSlug("researchrubrics-researcher"), + ), + ) + + task_mgmt_inngest.send.assert_awaited_once() + event = task_mgmt_inngest.send.await_args.args[0] + assert event.name == TaskReadyEvent.name + assert event.data["run_id"] == str(run_id) + assert event.data["definition_id"] == str(definition_id) + assert event.data["node_id"] == str(result.node_id) + finally: + cleanup_run(run_id, definition_id) diff --git a/tests/real_llm/artifact_health.py b/tests/real_llm/artifact_health.py new file mode 100644 index 00000000..24522ccf --- /dev/null +++ b/tests/real_llm/artifact_health.py @@ -0,0 +1,171 @@ +"""Pure artifact health checks for real-LLM rollout directories.""" + +import json +from pathlib import Path +from typing import Any # slopcop: ignore[no-typing-any] + +from pydantic import BaseModel, Field + + +class ArtifactHealthIssue(BaseModel): + """One machine-readable health issue found in a rollout artifact directory.""" + + code: str + message: str + + model_config = {"frozen": True} + + +class ArtifactHealthSummary(BaseModel): + """Rollout health summary derived from dumped files only.""" + + ok: bool + task_count: int + evaluation_count: int + resource_count: int + graph_node_count: int + criterion_count: int + normalized_scores: list[float] = Field(default_factory=list) + worker_slugs: list[str] = Field(default_factory=list) + issues: list[ArtifactHealthIssue] = Field(default_factory=list) + + model_config = {"frozen": True} + + +def _read_json(path: Path) -> dict[str, Any]: # slopcop: ignore[no-typing-any] + if not path.exists(): + return {} + return json.loads(path.read_text()) + + +def _read_jsonl(path: Path) -> list[dict[str, Any]]: # slopcop: ignore[no-typing-any] + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def _summary_json(row: dict[str, Any]) -> dict[str, Any]: # slopcop: ignore[no-typing-any] + summary = row.get("summary_json") or {} + if isinstance(summary, str): + return json.loads(summary) + return summary + + +def _criterion_has_reasoning(criterion: dict[str, Any]) -> bool: # slopcop: ignore[no-typing-any] + return bool(criterion.get("feedback") or criterion.get("model_reasoning")) + + +def analyze_rollout_artifacts( + out_dir: Path, + *, + expected_task_count: int | None = None, + expected_evaluation_count: int | None = None, + require_screenshots: bool = False, +) -> ArtifactHealthSummary: + """Analyze a rollout directory without importing DB/runtime models.""" + manifest = _read_json(out_dir / "manifest.json") + db_dir = out_dir / "db" + executions = _read_jsonl(db_dir / "run_task_executions.jsonl") + evaluations = _read_jsonl(db_dir / "run_task_evaluations.jsonl") + resources = _read_jsonl(db_dir / "run_resources.jsonl") + graph_nodes = _read_jsonl(db_dir / "run_graph_nodes.jsonl") + + task_count = len(executions) + evaluation_count = len(evaluations) + resource_count = len(resources) + graph_node_count = len(graph_nodes) + worker_slugs = sorted( + { + slug + for node in graph_nodes + if (slug := node.get("assigned_worker_slug") or node.get("assignedWorkerSlug")) + } + ) + + issues: list[ArtifactHealthIssue] = [] + if expected_task_count is not None and task_count != expected_task_count: + issues.append( + ArtifactHealthIssue( + code="task_count_mismatch", + message=f"Expected {expected_task_count} task executions, found {task_count}.", + ) + ) + if ( + expected_evaluation_count is not None + and evaluation_count < expected_evaluation_count + ): + issues.append( + ArtifactHealthIssue( + code="missing_evaluations", + message=( + f"Expected at least {expected_evaluation_count} evaluation rows, " + f"found {evaluation_count}." + ), + ) + ) + if resource_count == 0: + issues.append( + ArtifactHealthIssue( + code="missing_resources", + message="No resources were dumped for a completed rollout.", + ) + ) + if expected_task_count is not None and graph_node_count < expected_task_count: + issues.append( + ArtifactHealthIssue( + code="missing_graph_nodes", + message=( + f"Expected at least {expected_task_count} graph nodes, " + f"found {graph_node_count}." + ), + ) + ) + + normalized_scores: list[float] = [] + criterion_count = 0 + for row_idx, evaluation in enumerate(evaluations): + summary = _summary_json(evaluation) + normalized = summary.get("normalized_score", evaluation.get("score")) + if isinstance(normalized, int | float): + normalized_scores.append(float(normalized)) + + criteria = summary.get("criterion_results") or [] + criterion_count += len(criteria) + if not criteria: + issues.append( + ArtifactHealthIssue( + code="criteria_missing", + message=f"Evaluation row {row_idx} has no criterion_results.", + ) + ) + for criterion_idx, criterion in enumerate(criteria): + if not _criterion_has_reasoning(criterion): + issues.append( + ArtifactHealthIssue( + code="criterion_reasoning_missing", + message=( + f"Evaluation row {row_idx} criterion {criterion_idx} has no " + "feedback or model_reasoning." + ), + ) + ) + + if require_screenshots and not (manifest.get("screenshots") or {}): + issues.append( + ArtifactHealthIssue( + code="screenshots_missing", + message="Dashboard screenshots were requested but none were captured.", + ) + ) + + return ArtifactHealthSummary( + ok=not issues, + task_count=task_count, + evaluation_count=evaluation_count, + resource_count=resource_count, + graph_node_count=graph_node_count, + criterion_count=criterion_count, + normalized_scores=normalized_scores, + worker_slugs=worker_slugs, + issues=issues, + ) diff --git a/tests/real_llm/rollout.py b/tests/real_llm/rollout.py index 14b07dc7..eb8005ad 100644 --- a/tests/real_llm/rollout.py +++ b/tests/real_llm/rollout.py @@ -37,22 +37,7 @@ from typing import Any # slopcop: ignore[no-typing-any] from uuid import UUID -from sqlmodel import select - -from ergon_core.core.persistence.context.models import RunContextEvent -from ergon_core.core.persistence.graph.models import ( - RunGraphEdge, - RunGraphMutation, - RunGraphNode, -) -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.telemetry.models import ( - RunRecord, - RunResource, - RunTaskEvaluation, - RunTaskExecution, - SandboxEvent, -) +from tests.real_llm.artifact_health import analyze_rollout_artifacts logger = logging.getLogger(__name__) @@ -83,6 +68,15 @@ def _write_json_model(path: Path, row: Any) -> None: # slopcop: ignore[no-typin path.write_text(row.model_dump_json(indent=2)) +def _write_mapping_jsonl(path: Path, rows: list[dict[str, Any]]) -> int: # slopcop: ignore[no-typing-any] + """Write plain DB mapping rows as JSONL. Returns row count.""" + with path.open("w") as f: + for row in rows: + f.write(json.dumps(row, default=str)) + f.write("\n") + return len(rows) + + def dump_rollout(run_id: UUID, out_dir: Path) -> dict[str, int]: """Dump every persistence table for a run into ``out_dir/db/``. @@ -94,6 +88,27 @@ def dump_rollout(run_id: UUID, out_dir: Path) -> dict[str, int]: exact Pydantic schema — downstream readers can ``RunRecord.model_validate_json`` to round-trip. """ + # reason: importing persistence models at module import time triggers a + # context event payload <-> worker API cycle when unit tests import the + # pure report helpers from this module. The DB models are only needed for + # live rollout dumping, so keep this import scoped to that operation. + from sqlalchemy import text + from sqlmodel import select + + from ergon_core.core.persistence.graph.models import ( + RunGraphEdge, + RunGraphMutation, + RunGraphNode, + ) + from ergon_core.core.persistence.shared.db import get_session + from ergon_core.core.persistence.telemetry.models import ( + RunRecord, + RunResource, + RunTaskEvaluation, + RunTaskExecution, + SandboxEvent, + ) + db_dir = out_dir / "db" counts: dict[str, int] = {} @@ -144,11 +159,24 @@ def dump_rollout(run_id: UUID, out_dir: Path) -> dict[str, int]: ).all() ), ) - counts["run_context_events"] = _write_jsonl( + # Avoid importing RunContextEvent here: that model depends on context + # payloads, which currently have a circular import through api.Worker. + rows = [ + dict(row) + for row in session.connection() + .execute( + text( + "select * from run_context_events " + "where run_id = :run_id order by sequence asc, created_at asc" + ), + {"run_id": str(run_id)}, + ) + .mappings() + .all() + ] + counts["run_context_events"] = _write_mapping_jsonl( db_dir / "run_context_events.jsonl", - list( - session.exec(select(RunContextEvent).where(RunContextEvent.run_id == run_id)).all() - ), + rows, ) return counts @@ -280,6 +308,34 @@ def write_report(out_dir: Path, manifest_path: Path) -> Path: for table, n in sorted(counts.items()): lines.append(f"- `{table}`: {n}") lines.append("") + + health = analyze_rollout_artifacts(out_dir) + lines.extend( + [ + "## Artifact health", + "", + f"- status: **{'ok' if health.ok else 'unhealthy'}**", + f"- task executions: {health.task_count}", + f"- evaluations: {health.evaluation_count}", + f"- resources: {health.resource_count}", + f"- graph nodes: {health.graph_node_count}", + f"- criterion results: {health.criterion_count}", + ] + ) + if health.normalized_scores: + scores = ", ".join(f"{score:.3f}" for score in health.normalized_scores) + lines.append(f"- normalized scores: {scores}") + if health.worker_slugs: + slugs = ", ".join(f"`{slug}`" for slug in health.worker_slugs) + lines.append(f"- worker slugs: {slugs}") + if health.issues: + lines.append("") + lines.append("### Health issues") + lines.append("") + for issue in health.issues: + lines.append(f"- `{issue.code}`: {issue.message}") + lines.append("") + shots = manifest.get("screenshots") or {} if shots: lines.append("## Screenshots") diff --git a/tests/unit/cli/test_workflow_cli.py b/tests/unit/cli/test_workflow_cli.py index 4c587413..4737e8a7 100644 --- a/tests/unit/cli/test_workflow_cli.py +++ b/tests/unit/cli/test_workflow_cli.py @@ -5,6 +5,7 @@ import pytest from ergon_cli.commands.workflow import WorkflowCommandContext, execute_workflow_command +from ergon_core.core.runtime.services.task_management_dto import AddSubtaskResult from ergon_core.core.runtime.services.workflow_dto import WorkflowResourceRef @@ -28,6 +29,38 @@ def list_resources(self, session, *, run_id, node_id, scope, kind=None, max_dept return [self.resource] +class _ManagingService: + def __init__(self) -> None: + self.added = None + + async def add_task( + self, + session, + *, + run_id, + parent_node_id, + task_slug, + description, + assigned_worker_slug, + depends_on_task_slugs, + ): + assert isinstance(session, _Session) + self.added = { + "run_id": run_id, + "parent_node_id": parent_node_id, + "task_slug": task_slug, + "description": description, + "assigned_worker_slug": assigned_worker_slug, + "depends_on_task_slugs": depends_on_task_slugs, + } + + return AddSubtaskResult( + node_id=uuid4(), + task_slug="source-scout", + status="pending", + ) + + def test_resource_list_json_uses_injected_context() -> None: run_id = uuid4() node_id = uuid4() @@ -80,3 +113,57 @@ def test_agent_command_rejects_user_supplied_context_flags() -> None: session_factory=_Session, service=_Service(resource=None), # type: ignore[arg-type] ) + + +def test_parse_error_returns_nonzero_output_instead_of_system_exit() -> None: + output = execute_workflow_command( + "manage materialize-resource", + context=WorkflowCommandContext( + run_id=uuid4(), + node_id=uuid4(), + execution_id=uuid4(), + sandbox_task_key=uuid4(), + benchmark_type="researchrubrics", + ), + session_factory=_Session, + service=_Service(resource=None), # type: ignore[arg-type] + ) + + assert output.exit_code == 2 + assert output.stderr is not None + assert "--resource-id" in output.stderr + + +def test_manage_add_task_creates_subtask_with_injected_parent_context() -> None: + run_id = uuid4() + node_id = uuid4() + service = _ManagingService() + + output = execute_workflow_command( + "manage add-task --task-slug source-scout " + "--worker researchrubrics-researcher " + "--description 'Find authoritative sources' " + "--depends-on-task-slug prior-step " + "--format json", + context=WorkflowCommandContext( + run_id=run_id, + node_id=node_id, + execution_id=uuid4(), + sandbox_task_key=uuid4(), + benchmark_type="researchrubrics", + ), + session_factory=_Session, + service=service, + ) + + payload = json.loads(output.stdout) + assert output.exit_code == 0 + assert payload["task"]["task_slug"] == "source-scout" + assert service.added == { + "run_id": run_id, + "parent_node_id": node_id, + "task_slug": "source-scout", + "description": "Find authoritative sources", + "assigned_worker_slug": "researchrubrics-researcher", + "depends_on_task_slugs": ["prior-step"], + } diff --git a/tests/unit/runtime/test_failure_error_json.py b/tests/unit/runtime/test_failure_error_json.py new file mode 100644 index 00000000..4409eb44 --- /dev/null +++ b/tests/unit/runtime/test_failure_error_json.py @@ -0,0 +1,109 @@ +from contextlib import contextmanager +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from ergon_core.core.runtime.services.orchestration_dto import FailTaskExecutionCommand + + +def test_build_error_json_includes_stack_without_inferred_triage() -> None: + from ergon_core.core.runtime.errors.error_payload import ( + RuntimeErrorPayload, + build_error_json, + ) + + try: + raise RuntimeError( + "Invalid response from OpenAI chat completions endpoint: " + "choices.0.finish_reason input_value=None" + ) + except RuntimeError as exc: + payload = build_error_json(exc, phase="worker_execute") + + assert payload["message"].startswith("Invalid response from OpenAI") + assert payload["exception_type"] == "RuntimeError" + assert payload["phase"] == "worker_execute" + assert "Traceback" in payload["stack"] + assert "finish_reason" in payload["stack"] + assert "category" not in payload + assert "retryable" not in payload + assert RuntimeErrorPayload.model_validate(payload).message == payload["message"] + + +def test_worker_exception_result_carries_structured_error_json() -> None: + from ergon_core.core.runtime.inngest.worker_execute import ( + _worker_execute_result_from_exception, + ) + + try: + raise RuntimeError("provider timeout") + except RuntimeError as exc: + result = _worker_execute_result_from_exception(exc) + + assert result.success is False + assert result.error == "provider timeout" + assert result.error_json is not None + assert result.error_json["phase"] == "worker_execute" + assert result.error_json["exception_type"] == "RuntimeError" + + +@pytest.mark.asyncio +async def test_finalize_failure_preserves_structured_error_json(monkeypatch) -> None: + from ergon_core.core.runtime.services import task_execution_service as module + from ergon_core.core.runtime.services.task_execution_service import TaskExecutionService + + execution_id = uuid4() + run_id = uuid4() + node_id = uuid4() + execution = SimpleNamespace( + id=execution_id, + run_id=run_id, + node_id=node_id, + definition_task_id=None, + ) + + class Session: + def get(self, model, key): + assert key == execution_id + return execution + + def add(self, row): + assert row is execution + + def commit(self): + pass + + @contextmanager + def fake_get_session(): + yield Session() + + structured_error = { + "message": "provider returned malformed response", + "exception_type": "UnexpectedModelBehavior", + "phase": "worker_execute", + "stack": "Traceback ...", + } + + monkeypatch.setattr(module, "get_session", fake_get_session) + + async def fake_mark_failed_by_node(*args, **kwargs): + return None + + async def fake_emit_task_status(*args, **kwargs): + return None + + monkeypatch.setattr(module, "mark_task_failed_by_node", fake_mark_failed_by_node) + monkeypatch.setattr(module, "_emit_task_status", fake_emit_task_status) + + await TaskExecutionService().finalize_failure( + FailTaskExecutionCommand( + execution_id=execution_id, + run_id=run_id, + task_id=None, + error_message="provider returned malformed response", + error_json=structured_error, + ) + ) + + assert execution.error_json == structured_error diff --git a/tests/unit/runtime/test_real_llm_rollout_artifact_health.py b/tests/unit/runtime/test_real_llm_rollout_artifact_health.py new file mode 100644 index 00000000..774cf3a1 --- /dev/null +++ b/tests/unit/runtime/test_real_llm_rollout_artifact_health.py @@ -0,0 +1,188 @@ +"""Artifact health contracts for real-LLM rollout dumps.""" + +import json +from pathlib import Path +from uuid import uuid4 + +from tests.real_llm.artifact_health import analyze_rollout_artifacts +from tests.real_llm.rollout import write_report + + +def _write_jsonl(path: Path, rows: list[dict]) -> None: + path.write_text("".join(f"{json.dumps(row)}\n" for row in rows)) + + +def _write_minimal_rollout( + root: Path, + *, + task_count: int = 1, + evaluation_rows: list[dict] | None = None, + resource_count: int = 1, +) -> None: + db = root / "db" + db.mkdir() + (root / "manifest.json").write_text( + json.dumps( + { + "run_id": str(uuid4()), + "benchmark": "researchrubrics", + "worker": "researchrubrics-researcher", + "evaluator": "research-rubric", + "model": "stub:constant", + "cli_returncode": 0, + "terminal_status": "completed", + "wall_clock": {"duration_seconds": 1.0}, + "screenshots": {}, + "db_row_counts": { + "run_task_executions": task_count, + "run_task_evaluations": len(evaluation_rows or []), + "run_resources": resource_count, + "run_graph_nodes": task_count, + }, + } + ) + ) + _write_jsonl( + db / "run_task_executions.jsonl", + [ + { + "id": str(uuid4()), + "task_slug": f"task-{idx}", + "status": "completed", + } + for idx in range(task_count) + ], + ) + _write_jsonl( + db / "run_graph_nodes.jsonl", + [ + { + "id": str(uuid4()), + "task_slug": f"task-{idx}", + "status": "completed", + "assigned_worker_slug": "researchrubrics-researcher", + "level": 0, + } + for idx in range(task_count) + ], + ) + _write_jsonl(db / "run_resources.jsonl", [{"id": str(uuid4())} for _ in range(resource_count)]) + _write_jsonl(db / "run_task_evaluations.jsonl", evaluation_rows or []) + + +def test_artifact_health_fails_when_completed_tasks_lack_evaluations(tmp_path: Path) -> None: + _write_minimal_rollout(tmp_path, task_count=2, evaluation_rows=[]) + + health = analyze_rollout_artifacts(tmp_path, expected_task_count=2, expected_evaluation_count=2) + + assert health.ok is False + assert any(issue.code == "missing_evaluations" for issue in health.issues) + + +def test_artifact_health_requires_criterion_reasoning(tmp_path: Path) -> None: + _write_minimal_rollout( + tmp_path, + evaluation_rows=[ + { + "id": str(uuid4()), + "summary_json": { + "evaluator_name": "research-rubric", + "criterion_results": [ + { + "criterion_name": "criterion_0", + "criterion_type": "researchrubrics-llm-judge", + "score": 1.0, + "max_score": 1.0, + "passed": True, + "weight": 1.0, + "status": "passed", + "criterion_description": "Includes citations.", + "feedback": None, + "model_reasoning": None, + } + ], + }, + } + ], + ) + + health = analyze_rollout_artifacts(tmp_path, expected_task_count=1) + + assert health.ok is False + assert any(issue.code == "criterion_reasoning_missing" for issue in health.issues) + + +def test_artifact_health_summarizes_scores_and_workers(tmp_path: Path) -> None: + _write_minimal_rollout( + tmp_path, + evaluation_rows=[ + { + "id": str(uuid4()), + "score": 0.75, + "summary_json": { + "evaluator_name": "research-rubric", + "normalized_score": 0.75, + "criterion_results": [ + { + "criterion_name": "criterion_0", + "criterion_type": "researchrubrics-llm-judge", + "score": 1.0, + "max_score": 1.0, + "passed": True, + "weight": 1.0, + "status": "passed", + "criterion_description": "Includes citations.", + "feedback": "The report cited source material.", + "model_reasoning": "The report cited source material.", + } + ], + }, + } + ], + ) + + health = analyze_rollout_artifacts(tmp_path, expected_task_count=1) + + assert health.ok is True + assert health.task_count == 1 + assert health.evaluation_count == 1 + assert health.criterion_count == 1 + assert health.normalized_scores == [0.75] + assert health.worker_slugs == ["researchrubrics-researcher"] + + +def test_rollout_report_includes_artifact_health_section(tmp_path: Path) -> None: + _write_minimal_rollout( + tmp_path, + evaluation_rows=[ + { + "id": str(uuid4()), + "score": 0.75, + "summary_json": { + "evaluator_name": "research-rubric", + "normalized_score": 0.75, + "criterion_results": [ + { + "criterion_name": "criterion_0", + "criterion_type": "researchrubrics-llm-judge", + "score": 1.0, + "max_score": 1.0, + "passed": True, + "weight": 1.0, + "status": "passed", + "criterion_description": "Includes citations.", + "feedback": "The report cited source material.", + } + ], + }, + } + ], + ) + + report_path = write_report(tmp_path, tmp_path / "manifest.json") + + report = report_path.read_text() + assert "## Artifact health" in report + assert "- status: **ok**" in report + assert "- normalized scores: 0.750" in report + assert "- worker slugs: `researchrubrics-researcher`" in report diff --git a/tests/unit/state/test_openrouter_model_resolution.py b/tests/unit/state/test_openrouter_model_resolution.py new file mode 100644 index 00000000..4d2d6561 --- /dev/null +++ b/tests/unit/state/test_openrouter_model_resolution.py @@ -0,0 +1,13 @@ +from ergon_core.core.providers.generation.model_resolution import resolve_model_target + +# Importing the builtins registry registers production model backends. +import ergon_builtins.registry # noqa: F401 + + +def test_openrouter_target_resolves_to_openrouter_provider_model() -> None: + resolved = resolve_model_target("openrouter:anthropic/claude-sonnet-4.6") + + assert type(resolved.model).__name__ == "OpenRouterModel" + assert resolved.model.model_name == "anthropic/claude-sonnet-4.6" + assert resolved.model.system == "openrouter" + assert resolved.supports_logprobs is False diff --git a/tests/unit/state/test_research_rubrics_benchmark.py b/tests/unit/state/test_research_rubrics_benchmark.py index d56502c3..c656d63a 100644 --- a/tests/unit/state/test_research_rubrics_benchmark.py +++ b/tests/unit/state/test_research_rubrics_benchmark.py @@ -27,7 +27,10 @@ def test_researchrubrics_vanilla_registered(self): assert issubclass(ResearchRubricsVanillaBenchmark, Benchmark) def test_worker_slugs_registered(self): - expected = {"researchrubrics-researcher"} + expected = { + "researchrubrics-researcher", + "researchrubrics-workflow-cli-react", + } missing = expected - set(WORKERS.keys()) assert not missing, f"Expected worker slugs missing from registry: {missing}" @@ -35,6 +38,48 @@ def test_rubric_registered_by_cli_and_type_slug(self): assert EVALUATORS["research-rubric"] is ResearchRubricsRubric assert EVALUATORS["researchrubrics-rubric"] is ResearchRubricsRubric + def test_manager_composition_registers_specialist_bindings(self, monkeypatch): + from ergon_cli.composition import build_experiment + + class FakeTrainDataset: + def __len__(self): + return 1 + + def __getitem__(self, idx): + assert idx == 0 + return { + "sample_id": "sample", + "domain": "quality", + "ablated_prompt": "Write a report.", + "rubrics": [ + {"criterion": "Includes citations.", "axis": "quality", "weight": 2.0}, + ], + } + + def select(self, indexes): + assert list(indexes) == [0] + return self + + monkeypatch.setattr( + "ergon_builtins.benchmarks.researchrubrics.benchmark.load_dataset", + lambda *args, **kwargs: {"train": FakeTrainDataset()}, + ) + + experiment = build_experiment( + "researchrubrics", + model="stub:constant", + worker_slug="researchrubrics-workflow-cli-react", + evaluator_slug="research-rubric", + limit=1, + ) + + assert set(experiment.workers) == { + "manager", + "researchrubrics-researcher", + "researchrubrics-workflow-cli-react", + } + assert experiment.assignments == {"manager": ["sample"]} + class TestResearchRubricsVanillaBenchmark: """Verify the vanilla benchmark subclass.""" diff --git a/tests/unit/state/test_research_rubrics_workers.py b/tests/unit/state/test_research_rubrics_workers.py index 65e31f7d..af8e594b 100644 --- a/tests/unit/state/test_research_rubrics_workers.py +++ b/tests/unit/state/test_research_rubrics_workers.py @@ -14,6 +14,7 @@ ResearchRubricsResearcherWorker, ) from ergon_builtins.workers.research_rubrics.workflow_cli_react_worker import ( + _WORKFLOW_PROMPT, ResearchRubricsWorkflowCliReActWorker, ) from ergon_builtins.benchmarks.researchrubrics.toolkit_types import ( @@ -157,6 +158,11 @@ async def test_workflow_cli_worker_adds_workflow_tool(self): assert worker.type_slug == "researchrubrics-workflow-cli-react" assert "workflow" in tool_names + def test_workflow_cli_prompt_exposes_real_subtask_creation(self): + assert "manage add-task" in _WORKFLOW_PROMPT + assert "researchrubrics-researcher" in _WORKFLOW_PROMPT + assert "--dry-run" in _WORKFLOW_PROMPT + @pytest.mark.asyncio async def test_report_write_uses_manager_public_file_api(self): task_id = uuid4() diff --git a/tests/unit/state/test_workflow_cli_tool.py b/tests/unit/state/test_workflow_cli_tool.py index a52f7a6b..9805e514 100644 --- a/tests/unit/state/test_workflow_cli_tool.py +++ b/tests/unit/state/test_workflow_cli_tool.py @@ -2,6 +2,7 @@ import pytest from ergon_builtins.tools.workflow_cli_tool import make_workflow_cli_tool +from ergon_cli.commands.workflow import WorkflowCommandOutput, execute_workflow_command from ergon_core.api.worker_context import WorkerContext @@ -69,3 +70,56 @@ class Output: ) assert await workflow("inspect nope") == "workflow exited 2: bad command" + + +@pytest.mark.asyncio +async def test_workflow_tool_can_run_manage_commands_inside_event_loop() -> None: + context = WorkerContext( + run_id=uuid4(), + task_id=uuid4(), + execution_id=uuid4(), + sandbox_id="sandbox", + node_id=uuid4(), + ) + + def execute(command, *, context, session_factory, service): + assert command.startswith("manage add-task") + return WorkflowCommandOutput(stdout="created") + + workflow = make_workflow_cli_tool( + worker_context=context, + sandbox_task_key=context.task_id, + benchmark_type="researchrubrics", + execute_command=execute, + ) + + assert await workflow("manage add-task --task-slug source --worker worker --description x") == ( + "created" + ) + + +@pytest.mark.asyncio +async def test_workflow_tool_default_executor_handles_async_manage_bridge() -> None: + context = WorkerContext( + run_id=uuid4(), + task_id=uuid4(), + execution_id=uuid4(), + sandbox_id="sandbox", + node_id=uuid4(), + ) + + class Session: + def close(self): + pass + + workflow = make_workflow_cli_tool( + worker_context=context, + sandbox_task_key=context.task_id, + benchmark_type="researchrubrics", + execute_command=execute_workflow_command, + session_factory=Session, + ) + + result = await workflow("manage add-task --task-slug source --worker worker --description x --dry-run") + + assert "Graph lifecycle command validated" in result diff --git a/uv.lock b/uv.lock index d7bfcd87..26fdbdef 100644 --- a/uv.lock +++ b/uv.lock @@ -51,6 +51,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/a0/a73398d30bb0f9ad70cd70426151a4a19527a7296e48a3a16a50e1d5db05/ag_ui_protocol-0.1.15-py3-none-any.whl", hash = "sha256:85cde077023ccbc37b5ce2ad953537883c262d210320f201fc2ec4e85408b06a", size = 8661, upload-time = "2026-04-01T15:44:32.079Z" }, ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + [[package]] name = "aiofiles" version = "25.1.0" @@ -192,7 +204,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.93.0" +version = "0.97.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -204,9 +216,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/70/2429d6f7c2516db99fb342c3ad89575ab3e0cd31d3d2f6cba5fdf5e9c65b/anthropic-0.93.0.tar.gz", hash = "sha256:fea8376f7d5cdf99d5e8e85a48fe7a7bd8ab307cdfee4b1e8283a18b1c0ce1b5", size = 654155, upload-time = "2026-04-09T18:13:53.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/93/f66ea8bfe39f2e6bb9da8e27fa5457ad2520e8f7612dfc547b17fad55c4d/anthropic-0.97.0.tar.gz", hash = "sha256:021e79fd8e21e90ad94dc5ba2bbbd8b1599f424f5b1fab6c06204009cab764be", size = 669502, upload-time = "2026-04-23T20:52:34.445Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/7b/5b2c11902707c49c7a99418eb027ed3eb63876193fee5c80b5c878e3a673/anthropic-0.93.0-py3-none-any.whl", hash = "sha256:2c20b2ce6d305564c66a6cbaedddee8efdd3b9753098bf314093fcf4c662d04c", size = 627482, upload-time = "2026-04-09T18:13:51.606Z" }, + { url = "https://files.pythonhosted.org/packages/53/b6/8e851369fa661ad0fef2ae6266bf3b7d52b78ccf011720058f4adaca59e2/anthropic-0.97.0-py3-none-any.whl", hash = "sha256:8a1a472dfabcfc0c52ff6a3eecf724ac7e07107a2f6e2367be55ceb42f5d5613", size = 662126, upload-time = "2026-04-23T20:52:32.377Z" }, ] [[package]] @@ -280,6 +292,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "authlib" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" }, +] + [[package]] name = "bcrypt" version = "4.0.1" @@ -299,6 +324,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" }, ] +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -414,6 +448,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, ] +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "casbin" version = "1.43.0" @@ -806,6 +857,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/a3/80ff83dcad1ac61741714d97fce5a3ef42c201bb40005ec5cc413e34d75f/cupy_cuda12x-14.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:cafe62131caef63b5e90b71b617bb4bf47d7bd9e11cccabea8104db1e01db02e", size = 96822848, upload-time = "2026-02-20T10:23:42.684Z" }, ] +[[package]] +name = "cyclopts" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/fa/eff8f1abae783bade9b5e9bafafd0040d4dbf51988f9384bfdc0326ba1fc/cyclopts-4.11.0.tar.gz", hash = "sha256:1ffcb9990dbd56b90da19980d31596de9e99019980a215a5d76cf88fe452e94d", size = 170690, upload-time = "2026-04-23T00:23:36.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/37/197db187c260d24d4be1f09d427f59f3fb9a89bcf1354e23865c7bff7607/cyclopts-4.11.0-py3-none-any.whl", hash = "sha256:34318e3823b44b5baa754a5e37ec70a5c17dc81c65e4295ed70e17bc1aeae50d", size = 208494, upload-time = "2026-04-23T00:23:34.948Z" }, +] + [[package]] name = "datasets" version = "4.8.4" @@ -921,6 +987,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "e2b" version = "2.20.0" @@ -1128,7 +1203,7 @@ requires-dist = [ { name = "outlines", marker = "extra == 'dev'" }, { name = "psycopg2-binary", specifier = ">=2.9.9" }, { name = "pydantic", specifier = ">=2.5.0" }, - { name = "pydantic-ai" }, + { name = "pydantic-ai", specifier = ">=1.87.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, { name = "sqlmodel", specifier = ">=0.0.14" }, { name = "structlog", specifier = ">=23.2.0" }, @@ -1184,6 +1259,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -1193,6 +1277,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "fastapi" version = "0.135.3" @@ -1354,6 +1447,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/36/78e3a4044f88a4d7e5b214ff39ae76b925d8d0efafe2559b63062e3a94b8/fastcore-1.12.39-py3-none-any.whl", hash = "sha256:7299fa8ef35edf3db9e1eee452a5454672aceeb75673921686c0768859507b16", size = 102297, upload-time = "2026-04-13T22:34:02.609Z" }, ] +[[package]] +name = "fastmcp" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/13/29544fbc6dfe45ea38046af0067311e0bad7acc7d1f2ad38bb08f2409fe2/fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1", size = 28746127, upload-time = "2026-04-14T01:42:24.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/76/b310d52fa0e30d39bd937eb58ec2c1f1ea1b5f519f0575e9dd9612f01deb/fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9", size = 728599, upload-time = "2026-04-14T01:42:26.85Z" }, +] + [[package]] name = "fastuuid" version = "0.14.0" @@ -1489,6 +1615,19 @@ http = [ { name = "aiohttp" }, ] +[[package]] +name = "genai-prices" +version = "0.0.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/30/11f3d683cf3b1d9612475ad8bfffe3423ce9f50fc617733109033e73a038/genai_prices-0.0.57.tar.gz", hash = "sha256:6e101e9c53975557ceffa237b0995787d81fe75aac12410f2898504188bcad89", size = 66555, upload-time = "2026-04-21T13:42:52.554Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/fe/d0095040c120d97cb63d055224ecd4e913dc5655315c203c8e83bf13aa86/genai_prices-0.0.57-py3-none-any.whl", hash = "sha256:14e50fb69cdc5a06ddb2a6df5a7fe06741b9e44304ce3f1728f56abdf1856cca", size = 69654, upload-time = "2026-04-21T13:42:51.236Z" }, +] + [[package]] name = "genson" version = "1.3.0" @@ -1637,32 +1776,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, ] -[[package]] -name = "griffe" -version = "2.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "griffecli" }, - { name = "griffelib" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4a/49/eb6d2935e27883af92c930ed40cc4c69bcd32c402be43b8ca4ab20510f67/griffe-2.0.2.tar.gz", hash = "sha256:c5d56326d159f274492e9bf93a9895cec101155d944caa66d0fc4e0c13751b92", size = 293757, upload-time = "2026-03-27T11:34:52.205Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/c0/2bb018eecf9a83c68db9cd9fffd9dab25f102ad30ed869451046e46d1187/griffe-2.0.2-py3-none-any.whl", hash = "sha256:2b31816460aee1996af26050a1fc6927a2e5936486856707f55508e4c9b5960b", size = 5141, upload-time = "2026-03-27T11:34:47.721Z" }, -] - -[[package]] -name = "griffecli" -version = "2.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, - { name = "griffelib" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/e0/6a7d661d71bb043656a109b91d84a42b5342752542074ec83b16a6eb97f0/griffecli-2.0.2.tar.gz", hash = "sha256:40a1ad4181fc39685d025e119ae2c5b669acdc1f19b705fb9bf971f4e6f6dffb", size = 56281, upload-time = "2026-03-27T11:34:50.087Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e8/90d93356c88ac34c20cb5edffca68138df55ca9bbd1a06eccfbcec8fdbe5/griffecli-2.0.2-py3-none-any.whl", hash = "sha256:0d44d39e59afa81e288a3e1c3bf352cc4fa537483326ac06b8bb6a51fd8303a0", size = 9500, upload-time = "2026-03-27T11:34:48.81Z" }, -] - [[package]] name = "griffelib" version = "2.0.2" @@ -2008,6 +2121,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + [[package]] name = "jcs" version = "0.2.1" @@ -2017,6 +2163,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d4/9a99bc15266a842bd14a1913afdb05182888ebab035666c1ce8a64537ca2/jcs-0.2.1-py3-none-any.whl", hash = "sha256:e23a3e1de60f832d33cd811bb9c3b3be79219cdf95f63b88f0972732c3fa8476", size = 7603, upload-time = "2022-04-10T14:41:23.207Z" }, ] +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -2092,6 +2247,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "joserfc" +version = "1.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" }, +] + [[package]] name = "jsonpath-ng" version = "1.8.0" @@ -2101,6 +2268,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/99/33c7d78a3fb70d545fd5411ac67a651c81602cc09c9cf0df383733f068c5/jsonpath_ng-1.8.0-py3-none-any.whl", hash = "sha256:b8dde192f8af58d646fc031fac9c99fe4d00326afc4148f1f043c601a8cfe138", size = 67844, upload-time = "2026-02-28T00:53:19.637Z" }, ] +[[package]] +name = "jsonpath-python" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/db/2f4ecc24da35c6142b39c353d5b7c16eef955cc94b35a48d3fa47996d7c3/jsonpath_python-1.1.5.tar.gz", hash = "sha256:ceea2efd9e56add09330a2c9631ea3d55297b9619348c1055e5bfb9cb0b8c538", size = 87352, upload-time = "2026-03-17T06:16:40.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -2116,6 +2301,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] +[[package]] +name = "jsonschema-path" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/86/cfee6dd25843bec0760f456599a4f7e7e40221a934b9229fda0662c859bc/jsonschema_path-0.4.6.tar.gz", hash = "sha256:c89eb635f4d497c9ac328eeff359c489755838806a7d033510a692e9576f5c4b", size = 15302, upload-time = "2026-04-27T18:57:08.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/43/3d3065c05a04bb550c143bfbb8e4fd7022cd327e1082bf257bac74923783/jsonschema_path-0.4.6-py3-none-any.whl", hash = "sha256:451354b5311fa955c3144e6e4e255388c751c0121c5570ec5bb9291dd42d08c9", size = 19565, upload-time = "2026-04-27T18:57:06.792Z" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -2128,6 +2327,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "lark" version = "1.2.2" @@ -2201,6 +2417,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/ef/11292bb0b85cf4c93447cab5a29f64576ed14d3ab4280e35ddd23486594a/lm_format_enforcer-0.11.3-py3-none-any.whl", hash = "sha256:cf586350875def1ae7a8fba84fcbbfc8371424b6c9d05c1fcba70aa233fbf06f", size = 45418, upload-time = "2025-08-24T19:37:46.325Z" }, ] +[[package]] +name = "logfire" +version = "4.32.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/d7/70c6def7f3f459b2d57aa7fb37863d31b8d877e391547f200ee8c31d2e30/logfire-4.32.1.tar.gz", hash = "sha256:8e7ff418b5f2629c8a8e9426283ff82c760a30f24516c4c389d6cbb1d9768c58", size = 1089612, upload-time = "2026-04-15T14:11:57.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/77/70f6d97d7d74d2f2eeb695fe491b28906ae5c350b48516bb237ace9a1778/logfire-4.32.1-py3-none-any.whl", hash = "sha256:cb7873efec0e94a3de6e603539daaa6509a454599621c80dd227fbfa0ade37d4", size = 313021, upload-time = "2026-04-15T14:11:54.024Z" }, +] + +[package.optional-dependencies] +httpx = [ + { name = "opentelemetry-instrumentation-httpx" }, +] + [[package]] name = "logfire-api" version = "4.31.2" @@ -2362,23 +2601,21 @@ image = [ [[package]] name = "mistralai" -version = "1.12.4" +version = "2.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "eval-type-backport" }, { name = "httpx" }, - { name = "invoke" }, + { name = "jsonpath-python" }, { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, { name = "pydantic" }, { name = "python-dateutil" }, - { name = "pyyaml" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/12/c3476c53e907255b5f485f085ba50dd9a84b40fe662e9a888d6ded26fa7b/mistralai-1.12.4.tar.gz", hash = "sha256:e52b53bab58025dcd208eeac13e3c3df5778d4112eeca1f08124096c7738929f", size = 243129, upload-time = "2026-02-20T17:55:13.73Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c0/b3c48fed30e12881a519018662374da91242899fe7593478f0a2c44d566e/mistralai-2.4.3.tar.gz", hash = "sha256:82d671f29bfb161580ccd3f59eb0cf57c7bbc3c920d1ec436e55f82bf8d0034f", size = 417727, upload-time = "2026-04-27T12:55:41.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl", hash = "sha256:7b69fcbc306436491ad3377fbdead527c9f3a0ce145ec029bf04c6308ff2cca6", size = 509321, upload-time = "2026-02-20T17:55:15.27Z" }, + { url = "https://files.pythonhosted.org/packages/8d/34/a29192f8dab07222dd02c5499886a56ff08f64062edc0aabbb26b491742e/mistralai-2.4.3-py3-none-any.whl", hash = "sha256:06355f6473b1bffbf8cc60e352b873c53f72a4e9298366e5430db07f0ebb5310", size = 982753, upload-time = "2026-04-27T12:55:39.372Z" }, ] [[package]] @@ -2450,6 +2687,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/65/fa87a72b7bf150dbf9949e503b8a45e278789201ac3691b8af1cd861a7d6/modal-1.4.2-py3-none-any.whl", hash = "sha256:6993874476dfd51057e36c778dbd7aae58811802515532447336eced00c81e28", size = 802775, upload-time = "2026-04-16T20:27:58.065Z" }, ] +[[package]] +name = "more-itertools" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -2767,6 +3013,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/1f/c83cf5a206c263ee70448a5ae4264682555f4d0b5bed0d2cc6ca1108103d/openai_harmony-0.0.8-cp38-abi3-win_amd64.whl", hash = "sha256:39d44f0d8f466bd56698e7ead708bead3141e27b9b87e3ab7d5a6d0e4a869ee5", size = 2438369, upload-time = "2025-11-05T19:07:08.1Z" }, ] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + [[package]] name = "opencv-python-headless" version = "4.13.0.92" @@ -2787,32 +3045,32 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.41.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.41.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" }, + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.41.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -2823,14 +3081,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/46/d75a3f8c91915f2e58f61d0a2e4ada63891e7c7a37a20ff7949ba184a6b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0.tar.gz", hash = "sha256:f704201251c6f65772b11bddea1c948000554459101bdbb0116e0a01b70592f6", size = 25754, upload-time = "2026-04-09T14:38:37.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/48/b329fed2c610c2c32c9366d9dc597202c9d1e58e631c137ba15248d8850f/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad", size = 24650, upload-time = "2025-12-11T13:32:41.429Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/f6/b09e2e0c9f0b5750cebc6eaf31527b910821453cef40a5a0fe93550422b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0-py3-none-any.whl", hash = "sha256:3a1a86bd24806ccf136ec9737dbfa4c09b069f9130ff66b0acb014f9c5255fd1", size = 20299, upload-time = "2026-04-09T14:38:17.01Z" }, + { url = "https://files.pythonhosted.org/packages/81/a3/cc9b66575bd6597b98b886a2067eea2693408d2d5f39dad9ab7fc264f5f3/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18", size = 19766, upload-time = "2025-12-11T13:32:21.027Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.41.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -2841,48 +3099,88 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/08/11208bcfcab4fc2023252c3f322aa397fd9ad948355fea60f5fc98648603/opentelemetry_instrumentation_httpx-0.60b1.tar.gz", hash = "sha256:a506ebaf28c60112cbe70ad4f0338f8603f148938cb7b6794ce1051cd2b270ae", size = 20611, upload-time = "2025-12-11T13:37:01.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" }, + { url = "https://files.pythonhosted.org/packages/43/59/b98e84eebf745ffc75397eaad4763795bff8a30cbf2373a50ed4e70646c5/opentelemetry_instrumentation_httpx-0.60b1-py3-none-any.whl", hash = "sha256:f37636dd742ad2af83d896ba69601ed28da51fa4e25d1ab62fde89ce413e275b", size = 15701, upload-time = "2025-12-11T13:36:04.56Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.41.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.41.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/0e/a586df1186f9f56b5a0879d52653effc40357b8e88fc50fe300038c3c08b/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", size = 230181, upload-time = "2026-04-09T14:38:47.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/13/a7825118208cb32e6a4edcd0a99f925cbef81e77b3b0aedfd9125583c543/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd", size = 180214, upload-time = "2026-04-09T14:38:30.657Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.62b0" +version = "0.60b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/b0/c14f723e86c049b7bf8ff431160d982519b97a7be2857ed2247377397a24/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097", size = 145753, upload-time = "2026-04-09T14:38:48.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053, upload-time = "2025-12-11T13:37:25.115Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" }, ] [[package]] @@ -2962,11 +3260,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] @@ -3042,6 +3340,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, ] +[[package]] +name = "pathable" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, +] + [[package]] name = "pendulum" version = "3.2.0" @@ -3312,17 +3619,16 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.6" +version = "5.29.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" }, + { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" }, + { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" }, ] [[package]] @@ -3401,6 +3707,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + [[package]] name = "pyarrow" version = "23.0.1" @@ -3605,32 +3936,32 @@ email = [ [[package]] name = "pydantic-ai" -version = "0.7.2" +version = "1.87.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "google", "groq", "huggingface", "mcp", "mistral", "openai", "retries", "temporal", "vertexai"] }, + { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "spec", "temporal", "ui", "vertexai", "xai"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/d0/ca0dbea87aa677192fa4b663532bd37ae8273e883c55b661b786dbb52731/pydantic_ai-0.7.2.tar.gz", hash = "sha256:d215c323741d47ff13c6b48aa75aedfb8b6b5f9da553af709675c3078a4be4fc", size = 43763306, upload-time = "2025-08-14T22:59:58.912Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/79/63ea04239089b4165decc5b083d7ff30b255b771484054884458ead15126/pydantic_ai-1.87.0.tar.gz", hash = "sha256:4cc01cc73ff6d54b1726b7bae579b38145cce5d6a89e3b36b16f65a7b0bd7555", size = 13037, upload-time = "2026-04-25T01:09:19.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/77/402a278b9694cdfaeb5bf0ed4e0fee447de624aa67126ddcce8d98dc6062/pydantic_ai-0.7.2-py3-none-any.whl", hash = "sha256:a6e5d0994aa87385a05fdfdad7fda1fd14576f623635e4000883c4c7856eba13", size = 10188, upload-time = "2025-08-14T22:59:50.653Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8e/554d1aee91476e097541a4905c2d7f221844095804f9c4219e2a745c5eb4/pydantic_ai-1.87.0-py3-none-any.whl", hash = "sha256:24535d5dea2389ea6e2568f6cb95a039598207802dbe5c8c17efe7f71912734c", size = 7578, upload-time = "2026-04-25T01:09:09.97Z" }, ] [[package]] name = "pydantic-ai-slim" -version = "0.7.2" +version = "1.87.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "eval-type-backport" }, - { name = "griffe" }, + { name = "genai-prices" }, + { name = "griffelib" }, { name = "httpx" }, { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/39/87500c5e038296fe1becf62ac24f7e62dd5a1fb7fe63a9e29c58a2898b1a/pydantic_ai_slim-0.7.2.tar.gz", hash = "sha256:636ca32c8928048ba1173963aab6b7eb33b71174bbc371ad3f2096fee4c48dfe", size = 211787, upload-time = "2025-08-14T23:00:02.67Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/f9/76c7943f208b09b320dc65a000689929df6a5d3b143d56b48deade6db486/pydantic_ai_slim-1.87.0.tar.gz", hash = "sha256:25822985ca21d6f2995310da915080fc3f75763aec82e815a3388257b06d6b84", size = 573802, upload-time = "2026-04-25T01:09:21.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/93/fc3723a7cde4a8edb2d060fb8abeba22270ae61984796ab653fdd05baca0/pydantic_ai_slim-0.7.2-py3-none-any.whl", hash = "sha256:f5749d63bf4c2deac45371874df30d1d76a1572ce9467f6505926ecb835da583", size = 289755, upload-time = "2025-08-14T22:59:53.346Z" }, + { url = "https://files.pythonhosted.org/packages/d1/90/810dbc478bf063677cf56babc4404f2f0c59794017a5022ac93345a26d75/pydantic_ai_slim-1.87.0-py3-none-any.whl", hash = "sha256:6a9b4f9bcac3709ef47f3b3cda70446c002eb55901038a50d6224ee6743fe31a", size = 732159, upload-time = "2026-04-25T01:09:13.025Z" }, ] [package.optional-dependencies] @@ -3647,6 +3978,8 @@ bedrock = [ cli = [ { name = "argcomplete" }, { name = "prompt-toolkit" }, + { name = "pyperclip" }, + { name = "pyyaml" }, { name = "rich" }, ] cohere = [ @@ -3655,6 +3988,9 @@ cohere = [ evals = [ { name = "pydantic-evals" }, ] +fastmcp = [ + { name = "fastmcp" }, +] google = [ { name = "google-genai" }, ] @@ -3664,6 +4000,9 @@ groq = [ huggingface = [ { name = "huggingface-hub" }, ] +logfire = [ + { name = "logfire", extra = ["httpx"] }, +] mcp = [ { name = "mcp" }, ] @@ -3672,17 +4011,28 @@ mistral = [ ] openai = [ { name = "openai" }, + { name = "tiktoken" }, ] retries = [ { name = "tenacity" }, ] +spec = [ + { name = "pydantic-handlebars" }, + { name = "pyyaml" }, +] temporal = [ { name = "temporalio" }, ] +ui = [ + { name = "starlette" }, +] vertexai = [ { name = "google-auth" }, { name = "requests" }, ] +xai = [ + { name = "xai-sdk" }, +] [[package]] name = "pydantic-core" @@ -3739,7 +4089,7 @@ wheels = [ [[package]] name = "pydantic-evals" -version = "0.7.2" +version = "1.87.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3749,9 +4099,9 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/b7/005b1b23b96abf2bce880a4c10496c00f8ebd67690f6888e576269059f54/pydantic_evals-0.7.2.tar.gz", hash = "sha256:0cf7adee67b8a12ea0b41e5162c7256ae0f6a237acb1eea161a74ed6cf61615a", size = 44086, upload-time = "2025-08-14T23:00:03.606Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/51/477a52f0b6a9f534be3447af0f7dd972fef5c6dd5f917ed445ca8f632220/pydantic_evals-1.87.0.tar.gz", hash = "sha256:1b9431bafb7f887d462d2e52a536ffb73c25761ae97c907b2195da7dbf68fd15", size = 76576, upload-time = "2026-04-25T01:09:22.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/6f/3b844991fc1223f9c3b201f222397b0d115e236389bd90ced406ebc478ea/pydantic_evals-0.7.2-py3-none-any.whl", hash = "sha256:c7497d89659c35fbcaefbeb6f457ae09d62e36e161c4b25a462808178b7cfa92", size = 52753, upload-time = "2025-08-14T22:59:55.018Z" }, + { url = "https://files.pythonhosted.org/packages/15/75/b93f1126c0b20d993c9b59f5e85b405288e4cf1050d19161477e7a29e9bd/pydantic_evals-1.87.0-py3-none-any.whl", hash = "sha256:604bbe1c1124cf091d4f5772cd06df7b7a3fcefb884bb77937661bd2ca9b5e74", size = 91528, upload-time = "2026-04-25T01:09:14.956Z" }, ] [[package]] @@ -3774,7 +4124,7 @@ pycountry = [ [[package]] name = "pydantic-graph" -version = "0.7.2" +version = "1.87.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3782,9 +4132,21 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/a9/8a918b4dc2cd55775d854e076823fa9b60a390e4fbec5283916346556754/pydantic_graph-0.7.2.tar.gz", hash = "sha256:f90e4ec6f02b899bf6f88cc026dafa119ea5041ab4c62ba81497717c003a946e", size = 21804, upload-time = "2025-08-14T23:00:04.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/fa/b2306c6dbb06e4dfe6ce6b7c5a28b82bee536d965e1dd1800b49c386b389/pydantic_graph-1.87.0.tar.gz", hash = "sha256:0f44848f8e83908ce372491c32ef349dfaf05e29f39fade0bae9309ab4f015cd", size = 59251, upload-time = "2026-04-25T01:09:23.989Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/d7/639c69dda9e4b4cf376c9f45e5eae96721f2dc2f2dc618fb63142876dce4/pydantic_graph-0.7.2-py3-none-any.whl", hash = "sha256:b6189500a465ce1bce4bbc65ac5871149af8e0f81a15d54540d3dfc0cc9b2502", size = 27392, upload-time = "2025-08-14T22:59:56.564Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9a/e99edfa37527ee04c14db7fc4b8da3f8e9c913f91c541a4b2d08438b461e/pydantic_graph-1.87.0-py3-none-any.whl", hash = "sha256:fd39e4e852808e36163474fe2af48e88a046b5e5e00596730f33c17d2429b7d2", size = 73063, upload-time = "2026-04-25T01:09:16.493Z" }, +] + +[[package]] +name = "pydantic-handlebars" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/16/d41768bd3fd77e6250c20be11a3e68fee5fff07c3356455e6708f6a60f2a/pydantic_handlebars-0.1.0.tar.gz", hash = "sha256:1931c54946add1b5e3796c9bf6a005ed7662cef0109bb05c352f0b3d031a1260", size = 159826, upload-time = "2026-03-01T20:00:17.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5f/86b1630be61bdebf253c2f953a6c3f073ec21bb0725565ea3896802e1ca3/pydantic_handlebars-0.1.0-py3-none-any.whl", hash = "sha256:8a436fe8bc607295eb04bec58bd6e2c9498c9e069c557ff0b505e3d568c783bc", size = 40890, upload-time = "2026-03-01T20:00:16.106Z" }, ] [[package]] @@ -3871,6 +4233,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, ] +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -4012,6 +4383,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -4242,6 +4622,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + [[package]] name = "rich-toolkit" version = "0.19.7" @@ -4485,6 +4878,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", marker = "(python_full_version >= '3.14' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "jeepney", marker = "(python_full_version >= '3.14' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "sentencepiece" version = "0.2.1" @@ -4935,7 +5341,7 @@ wheels = [ [[package]] name = "temporalio" -version = "1.25.0" +version = "1.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nexus-rpc" }, @@ -4943,13 +5349,13 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/9c/3782bab0bf11a40b550147c19a5d1a476c17405391751982408902d9f138/temporalio-1.25.0.tar.gz", hash = "sha256:a3bbec1dcc904f674402cfa4faae480fda490b1c53ea5440c1f1996c562016fb", size = 2152534, upload-time = "2026-04-08T18:53:55.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/d4/fa21150a225393f87732ed6fef3cc9735d9e751edc6be415fe6e375105c6/temporalio-1.26.0.tar.gz", hash = "sha256:f4bfb35125e6f5e8c7f7ed1277c7354d812c6fac7ed5f8dbd50536cf289aaaa7", size = 2388994, upload-time = "2026-04-15T23:43:00.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/e3/5676dd10d1164b6d6ca8752314054097b89c5da931e936af402a7b15236c/temporalio-1.25.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6dc1bc8e1773b1a833d86a7ede2dd90ef4e031ced5b748b59e7f09a5bf9b327d", size = 13943906, upload-time = "2026-04-08T18:53:30.022Z" }, - { url = "https://files.pythonhosted.org/packages/89/50/7cbf7f845973be986ec165348f72f7a409750842a04d554965a39be5cb4f/temporalio-1.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3c8fdcf79ea5ae8ae2cf6f48072e4a86c3e0f4778f6a8a066c6ff1d336587db4", size = 13298719, upload-time = "2026-04-08T18:53:35.95Z" }, - { url = "https://files.pythonhosted.org/packages/d2/31/d474bab8535552add6ed289911bf1ffae5d7071823ece1069842190fcaed/temporalio-1.25.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:141f37aaafd7d090ba5c8776e4e9bc60df1fbc64b9f50c8f00e905a436588ddc", size = 13555435, upload-time = "2026-04-08T18:53:41.36Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c8/e7dc053d6107bf2a037a3c9fe7b86639a25dcb888bde0e1ca366901ee47f/temporalio-1.25.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7ca5bb80264976477d4dc7a839b3d22af8577ae92306526a061481db49bf92", size = 14052050, upload-time = "2026-04-08T18:53:46.44Z" }, - { url = "https://files.pythonhosted.org/packages/08/70/9340ed3a578321cbc153041d34834bb1ec3f1f3e3d9cded47cd1b7c3e403/temporalio-1.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9411534279a2e64847231b6059c214bff4d57cfd1532bd09f333d0b1603daa7f", size = 14299684, upload-time = "2026-04-08T18:53:52.482Z" }, + { url = "https://files.pythonhosted.org/packages/1e/27/8c421c622d18cc8e034247d5d72b89e6456937344b5bec1de40abef3c085/temporalio-1.26.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5489040c0cf621edeb36984199dd9e4fbd2b3a07d61a4f2a8da1f2cb9820ef26", size = 14221070, upload-time = "2026-04-15T23:42:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/49/7c/d2b691d16ec5db87198c2e08dbfba58e286c096faee15753613a581abdce/temporalio-1.26.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b18dd85771509c19ef059a31908bcd4e6130d1f67037c4db519702f3f2ad6d4a", size = 13583991, upload-time = "2026-04-15T23:42:34.357Z" }, + { url = "https://files.pythonhosted.org/packages/05/ca/b8728451320ca9d8bb6e1680b9bd23767118f86d5b8644edf2304d533f1b/temporalio-1.26.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46187d5f82ca2ae81f35ea5916a76db0e2f067210dc6b1852c3749475721946e", size = 13808036, upload-time = "2026-04-15T23:42:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/cb/54/3113f5e0ac58655790abac64656373e06191b351d74bfb94692e81bd6784/temporalio-1.26.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03300c3e5237443367ac61bb20bd726c656b3daa50310bdd436599d5bdc7cf97", size = 14336604, upload-time = "2026-04-15T23:42:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9b/c50840a26af3587c0c8d9af04d9976743e22496996dc1a377efc75dcd316/temporalio-1.26.0-cp310-abi3-win_amd64.whl", hash = "sha256:1c4a0d82f0a3796cbf78864c799f8dca0b94cdaec68e7b8b224c859005686ec4", size = 14525849, upload-time = "2026-04-15T23:42:57.589Z" }, ] [[package]] @@ -5317,6 +5723,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/68/35c1d87e608940badbcfeb630347aa0509897284684f61fab6423d02b253/uncalled_for-0.3.1.tar.gz", hash = "sha256:5e412ac6708f04b56bef5867b5dcf6690ebce4eb7316058d9c50787492bb4bca", size = 49693, upload-time = "2026-04-07T13:05:06.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/e1/7ec67882ad8fc9f86384bef6421fa252c9cbe5744f8df6ce77afc9eca1f5/uncalled_for-0.3.1-py3-none-any.whl", hash = "sha256:074cdc92da8356278f93d0ded6f2a66dd883dbecaf9bc89437646ee2289cc200", size = 11361, upload-time = "2026-04-07T13:05:05.341Z" }, +] + [[package]] name = "unidiff" version = "0.7.5" @@ -5594,6 +6009,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "xai-sdk" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/32/bb8385f7a3b05ce406b689aa000c9a34289caa1526f1c093a1cefc0d9695/xai_sdk-1.11.0.tar.gz", hash = "sha256:ca87a830d310fb8e06fba44fb2a8c5cdf0d9f716b61126eddd51b7f416a63932", size = 404313, upload-time = "2026-03-27T18:23:10.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/76/86d9a3589c725ce825d2ed3e7cb3ecf7f956d3fd015353d52197bb341bcd/xai_sdk-1.11.0-py3-none-any.whl", hash = "sha256:fe58ce6d8f8115ae8bd57ded57bcd847d0bb7cb28bb7b236abefd4626df1ed8d", size = 251388, upload-time = "2026-03-27T18:23:08.573Z" }, +] + [[package]] name = "xenon" version = "0.9.3" From ca3b720946e69664d4fa70f90765514d38872dde Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:20:42 +0100 Subject: [PATCH 11/66] Consolidate LLM context capture in builtins Move PydanticAI transcript capture, replay assembly, and model resolution out of core so ReAct workers persist richer context events without framework-specific core dependencies. Made-with: Cursor --- ...2026-04-27-react-worker-context-capture.md | 1116 +++++++++++++++++ .../researchrubrics/judge_criterion.py | 2 +- .../ergon_builtins/common/__init__.py | 1 + .../ergon_builtins/common/llm/__init__.py | 1 + .../common/llm}/structured_judge.py | 12 +- .../common/llm_context/__init__.py | 1 + .../common/llm_context/adapters/__init__.py | 1 + .../common/llm_context/adapters/base.py | 21 + .../llm_context/adapters/pydantic_ai.py | 221 ++++ .../evaluators/criteria/llm_judge.py | 2 +- .../models/cloud_passthrough.py | 2 +- .../models/openrouter_backend.py | 2 +- .../ergon_builtins/models/resolution.py | 110 ++ .../models/transformers_backend.py | 2 +- .../ergon_builtins/models/vllm_backend.py | 2 +- ergon_builtins/ergon_builtins/registry.py | 2 +- .../ergon_builtins/registry_core.py | 2 +- .../ergon_builtins/registry_local_models.py | 2 +- .../workers/baselines/react_worker.py | 164 +-- .../workers/research_rubrics/_run_skill.py | 2 +- .../core/persistence/context/assembly.py | 127 -- .../core/persistence/context/repository.py | 36 +- .../core/providers/generation/__init__.py | 11 +- .../providers/generation/model_resolution.py | 70 -- .../generation/pydantic_ai_format.py | 44 - ergon_core/ergon_core/core/rl/__init__.py | 16 - pyproject.toml | 2 +- .../builtins/common/test_capture_settings.py | 41 + .../common/test_transcript_adapters.py | 163 +++ .../test_context_event_repository.py | 107 ++ tests/unit/state/test_context_assembly.py | 8 +- .../unit/state/test_generation_turn_build.py | 8 +- .../state/test_openrouter_model_resolution.py | 5 +- .../workers/test_react_worker_contract.py | 9 + 34 files changed, 1852 insertions(+), 463 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-27-react-worker-context-capture.md create mode 100644 ergon_builtins/ergon_builtins/common/__init__.py create mode 100644 ergon_builtins/ergon_builtins/common/llm/__init__.py rename {ergon_core/ergon_core/core/providers/generation => ergon_builtins/ergon_builtins/common/llm}/structured_judge.py (72%) create mode 100644 ergon_builtins/ergon_builtins/common/llm_context/__init__.py create mode 100644 ergon_builtins/ergon_builtins/common/llm_context/adapters/__init__.py create mode 100644 ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py create mode 100644 ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py create mode 100644 ergon_builtins/ergon_builtins/models/resolution.py delete mode 100644 ergon_core/ergon_core/core/persistence/context/assembly.py delete mode 100644 ergon_core/ergon_core/core/providers/generation/model_resolution.py delete mode 100644 ergon_core/ergon_core/core/providers/generation/pydantic_ai_format.py create mode 100644 tests/unit/builtins/common/test_capture_settings.py create mode 100644 tests/unit/builtins/common/test_transcript_adapters.py create mode 100644 tests/unit/persistence/test_context_event_repository.py diff --git a/docs/superpowers/plans/2026-04-27-react-worker-context-capture.md b/docs/superpowers/plans/2026-04-27-react-worker-context-capture.md new file mode 100644 index 00000000..6890dc1b --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-react-worker-context-capture.md @@ -0,0 +1,1116 @@ +# ReAct Worker Context Capture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make real LLM ReAct workers persist the full model-context transcript, including thinking blocks and tool observations, into `run_context_events`. + +**Architecture:** Keep `RunContextEvent` as the canonical durable context log. Move PydanticAI-specific transcript parsing out of `ReActWorker` into `ergon_builtins.common.llm_context`, add a small capture-settings helper there for provider-specific thinking/logprob settings, and keep runtime persistence framework-neutral by consuming `GenerationTurn`. + +**Tech Stack:** Python, PydanticAI, SQLModel, `GenerationTurn`, `ContextEventRepository`, pytest. + +--- + +## Scope + +This plan covers backend capture only: + +- Turn on reasoning/thinking capture for real ReAct workers where PydanticAI/provider support exists. +- Extract PydanticAI message history into `GenerationTurn` via a reusable utility. +- Persist `GenerationTurn.tool_results` as `tool_result` rows. +- Add unit tests around transcript extraction, model settings, and context event persistence. + +This plan does not redesign the workspace Actions UI. After this lands, the existing Actions tab should automatically receive richer `contextEventsByTask` data for new runs. + +--- + +## File Map + +The extraction and capture-settings code belongs in `ergon_builtins`, not `ergon_core`, because it depends on concrete worker/framework behavior. `ergon_core` should keep only stable contracts and persistence: `GenerationTurn` in, `RunContextEvent` out. + +Within `ergon_builtins`, this should live in `common/llm_context/`: shared code for built-in LLM workers that is not specific to MiniF2F, SWE-Bench, ResearchRubrics, or any one benchmark. Keep this domain narrow: + +- `capture_settings.py` decides what provider settings to pass when we want transcript capture. +- `adapters/base.py` defines the common transcript adapter interface in both directions. +- `adapters/pydantic_ai.py` adapts PydanticAI message history into Ergon's framework-neutral `GenerationTurn`, reconstructs PydanticAI messages from `RunContextEvent` rows, and owns PydanticAI response-metadata parsing such as logprobs. +- Benchmark toolkits, prompts, sandbox code, and worker output policy stay where they are. + +If this refactor also consolidates model resolution out of core, keep that under `ergon_builtins.models`, not under `llm_context`. Model resolution is about selecting a concrete model backend; `llm_context` is about transcript capture/replay. + +```text +ergon_builtins/ + ergon_builtins/ + common/ # add: shared builtins utility package + __init__.py # add + llm/ + structured_judge.py # optional move: core structured_judge helper if moving model resolution + llm_context/ # add: shared LLM context-capture domain for built-in workers + __init__.py # add + capture_settings.py # add: provider-specific thinking/logprob model_settings + adapters/ # add: framework transcript adapters + __init__.py # add + base.py # add: TranscriptAdapter protocol/base interface + pydantic_ai.py # add: PydanticAI <-> GenerationTurn/RunContextEvent adapter + langgraph.py # do not add yet: reserved for future framework adapter + openai_sdk.py # do not add yet: reserved for future direct-SDK adapter + prompts.py # do not add: benchmark prompts stay under workers/benchmarks + tools.py # do not add: benchmark toolkits stay under tools/ or benchmarks/ + workers/ + baselines/ + react_worker.py # modify: call shared capture/extraction helpers + # remove: _build_turns + # remove: _to_turn + # remove: _extract_request_parts + # remove: _extract_response_parts + # remove: _extract_tool_results + # remove: _make_json_safe + # remove: transcript-only imports for dataclasses, + # PydanticAI request/response parts, + # Ergon part classes, extract_logprobs, + # and LOGPROB_SETTINGS + react_prompts.py # leave alone: benchmark/system prompt definitions + research_rubrics/ + researcher_worker.py # leave alone unless it later adopts PydanticAI transcript capture + workflow_cli_react_worker.py # leave alone unless it later adopts PydanticAI transcript capture + models/ + resolution.py # optional move: ResolvedModel/register/resolve from core + openrouter_backend.py # leave alone: model resolution backend already exists + vllm_backend.py # leave alone: model resolution backend already exists + cloud_passthrough.py # leave alone: passthrough backend behavior unchanged + tools/ # leave alone: tool definitions are not transcript extraction + +ergon_core/ + ergon_core/ + api/ + generation.py # existing contract: GenerationTurn stays framework-neutral + core/ + rl/ + __init__.py # modify: remove PydanticAI-specific LOGPROB_SETTINGS if unused + providers/ + generation/ + model_resolution.py # optional remove: move to ergon_builtins.models.resolution + structured_judge.py # optional remove: move to ergon_builtins.common.llm.structured_judge + capture_settings.py # do not add here + adapters/ # do not add framework adapters here + pydantic_ai_format.py # remove or stop using: behavior moves to PydanticAI adapter + persistence/ + context/ + repository.py # modify: persist tool_result events from turn.tool_results + models.py # existing table model: RunContextEvent + event_payloads.py # existing payload union: tool_result/thinking/etc. + assembly.py # remove: PydanticAI-specific resume assembly moves to adapter + +tests/ + unit/ + builtins/ + common/ + test_capture_settings.py # add: provider settings contract + test_transcript_adapters.py # add: base interface + PydanticAI adapter contract + providers/ + test_capture_settings.py # do not add here + test_transcript_adapters.py # do not add here + persistence/ + test_context_event_repository.py # add: tool_results -> tool_result rows + state/ + test_generation_turn_build.py # modify: import new transcript adapter + test_context_assembly.py # remove or move assertions into test_transcript_adapters.py + workers/ + test_react_worker_contract.py # modify: ReActWorker no longer owns parser helpers +``` + +Import direction: + +- `ergon_builtins.common.llm_context.*` may import `ergon_core.api.generation` and, if moved, `ergon_builtins.models.resolution.ResolvedModel`. +- `ergon_builtins.workers.baselines.react_worker` may import `ergon_builtins.common.llm_context.*`. +- `ergon_core` must not import `ergon_builtins`. + +Additional core consolidation in scope: + +- Move `ergon_core/ergon_core/core/persistence/context/assembly.py` into `PydanticAITranscriptAdapter` because it imports `pydantic_ai.messages` directly. +- Move `ergon_core/ergon_core/core/providers/generation/pydantic_ai_format.py` behavior into `adapters/pydantic_ai.py` or a private sibling under `ergon_builtins.common.llm_context.adapters`; it is only useful for PydanticAI response dumps. +- Move `LOGPROB_SETTINGS` out of `ergon_core.core.rl.__init__` if no RL code imports it after this refactor; it is currently a PydanticAI model-settings constant, not an RL-domain primitive. +- Optional but coherent: move `ergon_core/ergon_core/core/providers/generation/model_resolution.py` to `ergon_builtins/ergon_builtins/models/resolution.py`. It imports PydanticAI and is populated by builtins model backends. +- Optional but coherent: move `ergon_core/ergon_core/core/providers/generation/structured_judge.py` to `ergon_builtins/ergon_builtins/common/llm/structured_judge.py`. It constructs a PydanticAI `Agent` and is currently used by builtins evaluator/benchmark code. +- Do not move `ergon_core/api/generation.py`, `event_payloads.py`, `models.py`, or `repository.py`; those are the framework-neutral core domain. +- Do not move model backends (`openrouter_backend.py`, `vllm_backend.py`, `cloud_passthrough.py`) in this refactor; they already live in `ergon_builtins.models`. + +--- + +## Provider Settings Contract + +Use one settings helper instead of scattering provider checks through workers. + +Expected behavior: + +- `vllm:*` keeps existing logprob settings: + +```python +{"openai_logprobs": True, "openai_top_logprobs": 1} +``` + +- `anthropic:*` asks Anthropic for thinking blocks: + +```python +{"anthropic_thinking": {"type": "enabled", "budget_tokens": 1024}} +``` + +- `openrouter:*` asks OpenRouter to include reasoning: + +```python +{"openrouter_reasoning": {"enabled": True, "exclude": False}} +``` + +- `google:*` asks Gemini to include thoughts: + +```python +{"gemini_thinking_config": {"include_thoughts": True}} +``` + +- Unknown providers return `None`; provider-specific capture behavior must be added explicitly with tests. + +If provider settings conflict with a model/output mode at runtime, the implementation should fail loudly in tests first. Do not silently suppress thinking capture unless a targeted fallback is added with a test. + +--- + +## Task 1: Add Capture Settings Helper + +**Files:** + +- Create: `ergon_builtins/ergon_builtins/common/__init__.py` +- Create: `ergon_builtins/ergon_builtins/common/llm_context/__init__.py` +- Create: `ergon_builtins/ergon_builtins/common/llm_context/capture_settings.py` +- Create: `ergon_builtins/ergon_builtins/common/llm_context/adapters/__init__.py` +- Create: `ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py` +- Test: `tests/unit/builtins/common/test_capture_settings.py` + +- [ ] **Step 1: Write tests for provider-specific model settings** + +Create `tests/unit/builtins/common/test_capture_settings.py`: + +```python +from ergon_builtins.common.llm_context.capture_settings import build_capture_model_settings +from ergon_core.core.providers.generation.model_resolution import ResolvedModel + + +def _resolved(*, supports_logprobs: bool = False) -> ResolvedModel: + return ResolvedModel(model="dummy", supports_logprobs=supports_logprobs) + + +def test_vllm_enables_logprobs() -> None: + assert build_capture_model_settings("vllm:http://localhost:8000", _resolved(supports_logprobs=True)) == { + "openai_logprobs": True, + "openai_top_logprobs": 1, + } + + +def test_anthropic_enables_thinking() -> None: + assert build_capture_model_settings("anthropic:claude-sonnet-4", _resolved()) == { + "anthropic_thinking": {"type": "enabled", "budget_tokens": 1024}, + } + + +def test_openrouter_includes_reasoning() -> None: + assert build_capture_model_settings("openrouter:anthropic/claude-sonnet-4.6", _resolved()) == { + "openrouter_reasoning": {"enabled": True, "exclude": False}, + } + + +def test_google_includes_thoughts() -> None: + assert build_capture_model_settings("google:gemini-2.5-pro", _resolved()) == { + "gemini_thinking_config": {"include_thoughts": True}, + } + + +def test_unknown_provider_without_capture_returns_none() -> None: + assert build_capture_model_settings("openai:gpt-4o", _resolved()) is None +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +pytest tests/unit/builtins/common/test_capture_settings.py -q +``` + +Expected: FAIL because `capture_settings.py` does not exist. + +- [ ] **Step 3: Implement `capture_settings.py`** + +Create `ergon_builtins/ergon_builtins/common/__init__.py`: + +```python +"""Shared utilities for built-in Ergon workers.""" +``` + +Create `ergon_builtins/ergon_builtins/common/llm_context/__init__.py`: + +```python +"""Helpers for capturing LLM context from built-in worker frameworks.""" +``` + +Create `ergon_builtins/ergon_builtins/common/llm_context/adapters/__init__.py`: + +```python +"""Framework adapters for LLM transcript extraction and replay assembly.""" +``` + +Create `ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py`: + +```python +"""Base interface for framework transcript adapters.""" + +from typing import Protocol, TypeVar + +from ergon_core.api.generation import GenerationTurn +from ergon_core.core.persistence.context.models import RunContextEvent + +TranscriptT = TypeVar("TranscriptT") +ReplayT = TypeVar("ReplayT") + + +class TranscriptAdapter(Protocol[TranscriptT, ReplayT]): + """Convert between framework-native transcripts and Ergon context events.""" + + def build_turns(self, transcript: TranscriptT) -> list[GenerationTurn]: + """Return ordered turns extracted from a complete transcript.""" + ... + + def assemble_replay(self, events: list[RunContextEvent]) -> ReplayT: + """Return framework-native replay context from ordered context events.""" + ... +``` + +Create `ergon_builtins/ergon_builtins/common/llm_context/capture_settings.py`: + +```python +"""Provider-specific settings for capturing model context events. + +Workers call this once before running an agent. The returned dictionary is +passed to PydanticAI as model_settings. +""" + +from ergon_core.api.json_types import JsonObject +from ergon_core.core.providers.generation.model_resolution import ResolvedModel +_ANTHROPIC_THINKING_BUDGET_TOKENS = 1024 +_OPENAI_COMPAT_LOGPROB_SETTINGS: JsonObject = { + "openai_logprobs": True, + "openai_top_logprobs": 1, +} + + +def _prefix(model_target: str | None) -> str: + target = model_target or "" + return target.split(":", 1)[0] if ":" in target else "" + + +def build_capture_model_settings( + model_target: str | None, + resolved_model: ResolvedModel, +) -> JsonObject | None: + """Return PydanticAI model_settings for transcript capture.""" + prefix = _prefix(model_target) + + if prefix == "vllm" and resolved_model.supports_logprobs: + return dict(_OPENAI_COMPAT_LOGPROB_SETTINGS) + + if prefix == "anthropic": + return { + "anthropic_thinking": { + "type": "enabled", + "budget_tokens": _ANTHROPIC_THINKING_BUDGET_TOKENS, + } + } + + if prefix == "openrouter": + return { + "openrouter_reasoning": { + "enabled": True, + "exclude": False, + } + } + + if prefix == "google": + return { + "gemini_thinking_config": { + "include_thoughts": True, + } + } + + return None +``` + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: + +```bash +pytest tests/unit/builtins/common/test_capture_settings.py -q +``` + +Expected: PASS. + +--- + +## Task 2: Extract PydanticAI Transcript Conversion + +**Files:** + +- Create: `ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py` +- Test: `tests/unit/builtins/common/test_transcript_adapters.py` +- Modify: `tests/unit/state/test_generation_turn_build.py` + +- [ ] **Step 1: Write tests for transcript extraction** + +Create `tests/unit/builtins/common/test_transcript_adapters.py`: + +```python +from ergon_core.api.generation import ( + GenerationTurn, + TextPart as ErgonTextPart, + ThinkingPart as ErgonThinkingPart, + ToolCallPart as ErgonToolCallPart, + ToolReturnPart as ErgonToolReturnPart, + UserPromptPart as ErgonUserPromptPart, +) +from ergon_builtins.common.llm_context.adapters.base import TranscriptAdapter +from ergon_builtins.common.llm_context.adapters.pydantic_ai import ( + PydanticAITranscriptAdapter, +) +from pydantic_ai.messages import ( + ModelRequest, + ModelResponse, + TextPart, + ThinkingPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) + + +def test_text_and_thinking_are_response_parts() -> None: + adapter: TranscriptAdapter[list[ModelRequest | ModelResponse], list[ModelRequest | ModelResponse]] = ( + PydanticAITranscriptAdapter() + ) + turns = adapter.build_turns( + [ + ModelRequest(parts=[UserPromptPart(content="hard question")]), + ModelResponse( + parts=[ + ThinkingPart(content="let me reason"), + TextPart(content="answer"), + ] + ), + ] + ) + + assert len(turns) == 1 + turn = turns[0] + assert isinstance(turn, GenerationTurn) + assert any(isinstance(part, ErgonUserPromptPart) for part in turn.messages_in) + assert any(isinstance(part, ErgonThinkingPart) for part in turn.response_parts) + assert any(isinstance(part, ErgonTextPart) for part in turn.response_parts) + + +def test_tool_return_is_attached_to_generating_turn() -> None: + adapter = PydanticAITranscriptAdapter() + turns = adapter.build_turns( + [ + ModelRequest(parts=[UserPromptPart(content="search")]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name="search", + tool_call_id="call-1", + args={"query": "ergon"}, + ) + ] + ), + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="search", + tool_call_id="call-1", + content={"result": "found"}, + ) + ] + ), + ModelResponse(parts=[TextPart(content="done")]), + ] + ) + + assert len(turns) == 2 + first = turns[0] + assert any(isinstance(part, ErgonToolCallPart) for part in first.response_parts) + assert len(first.tool_results) == 1 + result = first.tool_results[0] + assert isinstance(result, ErgonToolReturnPart) + assert result.tool_call_id == "call-1" + assert result.tool_name == "search" + assert result.content == '{"result": "found"}' +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +pytest tests/unit/builtins/common/test_transcript_adapters.py -q +``` + +Expected: FAIL because `adapters/pydantic_ai.py` does not exist. + +- [ ] **Step 3: Implement the transcript utility** + +Create `ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py` by moving the existing parsing helpers out of `react_worker.py`: + +```python +"""PydanticAI transcript adapter.""" + +import dataclasses # slopcop: ignore[no-dataclass] +import json +from typing import Any + +from ergon_core.api.generation import ( + GenerationTurn, + SystemPromptPart, + TextPart, + ThinkingPart, + TokenLogprob, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) +from ergon_core.core.persistence.context.event_payloads import ( + AssistantTextPayload, + SystemPromptPayload, + ThinkingPayload, + ToolCallPayload, + ToolResultPayload, + UserMessagePayload, +) +from ergon_core.core.persistence.context.models import RunContextEvent +from ergon_builtins.common.llm_context.adapters.base import TranscriptAdapter +from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse +from pydantic_ai.messages import SystemPromptPart as PydanticSystemPromptPart +from pydantic_ai.messages import TextPart as PydanticTextPart +from pydantic_ai.messages import ThinkingPart as PydanticThinkingPart +from pydantic_ai.messages import ToolCallPart as PydanticToolCallPart +from pydantic_ai.messages import ToolReturnPart as PydanticToolReturnPart +from pydantic_ai.messages import UserPromptPart as PydanticUserPromptPart + + +class PydanticAITranscriptAdapter(TranscriptAdapter[list[ModelMessage], list[ModelMessage]]): + """Convert complete PydanticAI message history into Ergon turns.""" + + def build_turns(self, transcript: list[ModelMessage]) -> list[GenerationTurn]: + """Build turns from a complete PydanticAI message list. + + The full message history is required because tool returns appear in the + request after the response that created the tool call. + """ + turns: list[GenerationTurn] = [] + pending_response: ModelResponse | None = None + pending_request_in: ModelRequest | None = None + + for message in transcript: + if isinstance(message, ModelRequest): + if pending_response is not None: + turns.append( + _to_turn( + pending_request_in, + pending_response, + tool_result_request=message, + ) + ) + pending_response = None + pending_request_in = None + pending_request_in = message + elif isinstance(message, ModelResponse): + pending_response = message + + if pending_response is not None: + turns.append(_to_turn(pending_request_in, pending_response, tool_result_request=None)) + + return turns + + def assemble_replay(self, events: list[RunContextEvent]) -> list[ModelMessage]: + """Reconstruct PydanticAI messages from ordered context events.""" + messages: list[ModelMessage] = [] + current_request_parts: list[Any] = [] + current_response_parts: list[Any] = [] + + for event in events: + if event.event_type in ("system_prompt", "user_message"): + current_request_parts.append(_to_pydantic_request_part(event)) + elif event.event_type in ("thinking", "assistant_text", "tool_call"): + if current_request_parts and not current_response_parts: + messages.append(ModelRequest(parts=current_request_parts)) + current_request_parts = [] + current_response_parts.append(_to_pydantic_response_part(event)) + elif event.event_type == "tool_result": + if current_response_parts: + messages.append(ModelResponse(parts=current_response_parts)) + current_response_parts = [] + current_request_parts.append(_to_pydantic_request_part(event)) + + if current_response_parts: + messages.append(ModelResponse(parts=current_response_parts)) + + return messages + + +def _to_turn( + request_in: ModelRequest | None, + response: ModelResponse, + tool_result_request: ModelRequest | None, +) -> GenerationTurn: + raw_resp = _make_json_safe(dataclasses.asdict(response)) + return GenerationTurn( + messages_in=_extract_request_parts(request_in) if request_in else [], + response_parts=_extract_response_parts(response), + tool_results=_extract_tool_results(tool_result_request) if tool_result_request else [], + turn_logprobs=extract_logprobs(raw_resp), + ) + + +def extract_logprobs(raw: dict[str, Any]) -> list[TokenLogprob] | None: + """Extract per-token logprobs from a PydanticAI response dump.""" + details = raw.get("provider_details") + if not isinstance(details, dict): + return None + raw_logprobs = details.get("logprobs") + if not isinstance(raw_logprobs, list) or not raw_logprobs: + return None + return [ + TokenLogprob( + token=entry["token"], + logprob=entry["logprob"], + top_logprobs=entry.get("top_logprobs", []), + ) + for entry in raw_logprobs + if isinstance(entry, dict) and "token" in entry and "logprob" in entry + ] + + +def _to_pydantic_response_part(event: RunContextEvent) -> Any: # slopcop: ignore[no-typing-any] + parsed = event.parsed_payload() + if event.event_type == "thinking": + if not isinstance(parsed, ThinkingPayload): + raise ValueError(f"Expected ThinkingPayload for thinking event, got {type(parsed)}") + return PydanticThinkingPart(content=parsed.text) + if event.event_type == "assistant_text": + if not isinstance(parsed, AssistantTextPayload): + raise ValueError(f"Expected AssistantTextPayload for assistant_text event, got {type(parsed)}") + return PydanticTextPart(content=parsed.text) + if event.event_type == "tool_call": + if not isinstance(parsed, ToolCallPayload): + raise ValueError(f"Expected ToolCallPayload for tool_call event, got {type(parsed)}") + return PydanticToolCallPart( + tool_name=parsed.tool_name, + tool_call_id=parsed.tool_call_id, + args=parsed.args, + ) + raise ValueError(f"Unexpected response event_type: {event.event_type!r}") + + +def _to_pydantic_request_part(event: RunContextEvent) -> Any: # slopcop: ignore[no-typing-any] + parsed = event.parsed_payload() + if event.event_type == "system_prompt": + if not isinstance(parsed, SystemPromptPayload): + raise ValueError(f"Expected SystemPromptPayload for system_prompt event, got {type(parsed)}") + return PydanticSystemPromptPart(content=parsed.text) + if event.event_type == "user_message": + if not isinstance(parsed, UserMessagePayload): + raise ValueError(f"Expected UserMessagePayload for user_message event, got {type(parsed)}") + return PydanticUserPromptPart(content=parsed.text) + if event.event_type == "tool_result": + if not isinstance(parsed, ToolResultPayload): + raise ValueError(f"Expected ToolResultPayload for tool_result event, got {type(parsed)}") + return PydanticToolReturnPart( + tool_call_id=parsed.tool_call_id, + tool_name=parsed.tool_name, + content=str(parsed.result), + ) + raise ValueError(f"Unexpected request event_type: {event.event_type!r}") + + +def _extract_request_parts(request: ModelRequest) -> list[Any]: # slopcop: ignore[no-typing-any] + parts: list[Any] = [] # slopcop: ignore[no-typing-any] + for part in request.parts: + if isinstance(part, PydanticSystemPromptPart): + parts.append(SystemPromptPart(content=part.content)) + elif isinstance(part, PydanticUserPromptPart) and isinstance(part.content, str): + parts.append(UserPromptPart(content=part.content)) + return parts + + +def _extract_response_parts(response: ModelResponse) -> list[Any]: # slopcop: ignore[no-typing-any] + parts: list[Any] = [] # slopcop: ignore[no-typing-any] + for part in response.parts: + if isinstance(part, PydanticTextPart): + parts.append(TextPart(content=part.content)) + elif isinstance(part, PydanticToolCallPart): + parts.append( + ToolCallPart( + tool_name=part.tool_name, + tool_call_id=part.tool_call_id, + args=part.args_as_dict(), + ) + ) + elif isinstance(part, PydanticThinkingPart): + parts.append(ThinkingPart(content=part.content)) + return parts + + +def _extract_tool_results(request: ModelRequest) -> list[ToolReturnPart]: + results: list[ToolReturnPart] = [] + for part in request.parts: + if isinstance(part, PydanticToolReturnPart): + content = part.content + serialized = content if isinstance(content, str) else json.dumps(content, default=str) + results.append( + ToolReturnPart( + tool_call_id=part.tool_call_id, + tool_name=part.tool_name, + content=serialized, + ) + ) + return results + + +def _make_json_safe(obj: Any) -> Any: # slopcop: ignore[no-typing-any] + from datetime import datetime + + if isinstance(obj, dict): + return {k: _make_json_safe(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_make_json_safe(v) for v in obj] + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, bytes): + return obj.decode("utf-8", errors="replace") + return obj +``` + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: + +```bash +pytest tests/unit/builtins/common/test_transcript_adapters.py -q +``` + +Expected: PASS. + +- [ ] **Step 5: Update old generation-turn tests to import the new utility** + +Modify `tests/unit/state/test_generation_turn_build.py`: + +```python +from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter + + +def _build_turns(messages): + return PydanticAITranscriptAdapter().build_turns(messages) +``` + +Remove the old import from `ergon_builtins.workers.baselines.react_worker`. + +- [ ] **Step 6: Run the old and new transcript tests together** + +Run: + +```bash +pytest tests/unit/state/test_generation_turn_build.py tests/unit/builtins/common/test_transcript_adapters.py -q +``` + +Expected: PASS. + +--- + +## Task 3: Simplify `ReActWorker` + +**Files:** + +- Modify: `ergon_builtins/ergon_builtins/workers/baselines/react_worker.py` +- Test: `tests/unit/workers/test_react_worker_contract.py` + +- [ ] **Step 1: Add a contract test that transcript helpers no longer live in `react_worker.py`** + +Modify `tests/unit/workers/test_react_worker_contract.py`: + +```python +def test_pydantic_ai_transcript_adapter_lives_outside_worker() -> None: + import ergon_builtins.workers.baselines.react_worker as react_worker + + assert not hasattr(react_worker, "_build_turns") + assert not hasattr(react_worker, "_extract_request_parts") + assert not hasattr(react_worker, "_extract_response_parts") + assert not hasattr(react_worker, "_extract_tool_results") +``` + +- [ ] **Step 2: Run the contract test and verify it fails** + +Run: + +```bash +pytest tests/unit/workers/test_react_worker_contract.py -q +``` + +Expected: FAIL because helper functions still exist in `react_worker.py`. + +- [ ] **Step 3: Update `ReActWorker` imports** + +In `ergon_builtins/ergon_builtins/workers/baselines/react_worker.py`, remove imports that are only used by transcript parsing: + +```python +import dataclasses # remove +from ergon_core.api.generation import SystemPromptPart, TextPart, ThinkingPart, ToolCallPart, ToolReturnPart, UserPromptPart # remove +from ergon_core.api.json_types import JsonObject # remove if only used for model_settings type +from ergon_core.core.providers.generation.pydantic_ai_format import extract_logprobs # remove +from ergon_core.core.rl import LOGPROB_SETTINGS # remove +from pydantic_ai.messages import ModelRequest, ModelResponse # remove +from pydantic_ai.messages import SystemPromptPart as PydanticSystemPromptPart # remove +from pydantic_ai.messages import TextPart as PydanticTextPart # remove +from pydantic_ai.messages import ThinkingPart as PydanticThinkingPart # remove +from pydantic_ai.messages import ToolCallPart as PydanticToolCallPart # remove +from pydantic_ai.messages import ToolReturnPart as PydanticToolReturnPart # remove +from pydantic_ai.messages import UserPromptPart as PydanticUserPromptPart # remove +``` + +Add: + +```python +from ergon_builtins.common.llm_context.capture_settings import build_capture_model_settings +from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter +``` + +- [ ] **Step 4: Update model settings and transcript extraction** + +Replace: + +```python +model_settings: JsonObject | None = None +if resolved.supports_logprobs and self.model and self.model.startswith("vllm:"): + model_settings = LOGPROB_SETTINGS +``` + +with: + +```python +model_settings = build_capture_model_settings(self.model, resolved) +``` + +Replace: + +```python +turns = _build_turns(run.ctx.state.message_history) +``` + +with: + +```python +turns = PydanticAITranscriptAdapter().build_turns(run.ctx.state.message_history) +``` + +- [ ] **Step 5: Delete transcript helper functions from `react_worker.py`** + +Delete the helper block that starts at: + +```python +# --------------------------------------------------------------------------- +# PydanticAI message → GenerationTurn +# --------------------------------------------------------------------------- +``` + +Keep `_format_task` and `_latest_final_result_message` in `react_worker.py` because they are worker behavior, not PydanticAI transcript parsing. + +- [ ] **Step 6: Run contract and worker tests** + +Run: + +```bash +pytest tests/unit/workers/test_react_worker_contract.py tests/unit/state/test_generation_turn_build.py tests/unit/builtins/common/test_capture_settings.py tests/unit/builtins/common/test_transcript_adapters.py -q +``` + +Expected: PASS. + +--- + +## Task 4: Persist `GenerationTurn.tool_results` + +**Files:** + +- Modify: `ergon_core/ergon_core/core/persistence/context/repository.py` +- Test: `tests/unit/persistence/test_context_event_repository.py` + +- [ ] **Step 1: Write a failing repository test** + +Create `tests/unit/persistence/test_context_event_repository.py`: + +```python +from uuid import UUID + +import pytest +from ergon_core.api.generation import GenerationTurn, ToolCallPart, ToolReturnPart, UserPromptPart +from ergon_core.core.persistence.context.repository import ContextEventRepository +from ergon_core.core.persistence.telemetry.models import RunRecord, RunTaskExecution +from ergon_core.core.persistence.shared.ids import new_id +from sqlmodel import Session + + +@pytest.mark.asyncio +async def test_persist_turn_records_tool_results_from_tool_results(session: Session) -> None: + run_id = new_id() + execution_id = new_id() + + session.add(RunRecord(id=run_id, experiment_id=UUID(int=1), name="test", status="running")) + session.add( + RunTaskExecution( + id=execution_id, + run_id=run_id, + definition_task_id=UUID(int=2), + node_id=UUID(int=3), + attempt_number=1, + status="running", + ) + ) + session.commit() + + repo = ContextEventRepository() + events = await repo.persist_turn( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker", + turn=GenerationTurn( + messages_in=[UserPromptPart(content="search")], + response_parts=[ + ToolCallPart(tool_name="search", tool_call_id="call-1", args={"query": "ergon"}) + ], + tool_results=[ + ToolReturnPart(tool_name="search", tool_call_id="call-1", content="found") + ], + ), + ) + + assert [event.event_type for event in events] == ["user_message", "tool_call", "tool_result"] + tool_result = events[-1].parsed_payload() + assert tool_result.event_type == "tool_result" + assert tool_result.tool_name == "search" + assert tool_result.tool_call_id == "call-1" + assert tool_result.result == "found" +``` + +If the project uses a differently named DB fixture than `session`, adapt only the fixture name and setup rows to the existing test harness. Keep the assertion shape unchanged. + +- [ ] **Step 2: Run the focused repository test and verify it fails** + +Run: + +```bash +pytest tests/unit/persistence/test_context_event_repository.py -q +``` + +Expected: FAIL because `_events_from_tool_results` currently scans `turn.messages_in`, not `turn.tool_results`. + +- [ ] **Step 3: Update `_events_from_tool_results`** + +In `ergon_core/ergon_core/core/persistence/context/repository.py`, replace the loop source: + +```python +for part in turn.messages_in: +``` + +with a helper that prefers `turn.tool_results` and preserves compatibility with old/custom workers: + +```python +tool_result_parts = [ + *turn.tool_results, + *(part for part in turn.messages_in if isinstance(part, ToolReturnPart)), +] +for part in tool_result_parts: +``` + +Update the docstring to: + +```python +"""Produce tool_result events from GenerationTurn tool observations.""" +``` + +- [ ] **Step 4: Run the focused repository test and verify it passes** + +Run: + +```bash +pytest tests/unit/persistence/test_context_event_repository.py -q +``` + +Expected: PASS. + +--- + +## Task 5: Add End-to-End Unit Coverage for ReAct Capture Shape + +**Files:** + +- Modify or create: `tests/unit/builtins/common/test_transcript_adapters.py` +- Modify or create: `tests/unit/persistence/test_context_event_repository.py` + +- [ ] **Step 1: Add a combined transcript-to-event regression** + +Add a test that builds PydanticAI messages, converts them to `GenerationTurn`, persists the first turn, and asserts event types: + +```python +@pytest.mark.asyncio +async def test_pydantic_ai_tool_observation_becomes_context_event(session: Session) -> None: + from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter + + turns = PydanticAITranscriptAdapter().build_turns( + [ + ModelRequest(parts=[UserPromptPart(content="search")]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name="search", + tool_call_id="call-1", + args={"query": "ergon"}, + ) + ] + ), + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="search", + tool_call_id="call-1", + content="found", + ) + ] + ), + ] + ) + + events = await repo.persist_turn( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker", + turn=turns[0], + ) + + assert [event.event_type for event in events] == ["user_message", "tool_call", "tool_result"] +``` + +Use the same DB setup helper from Task 4. This test is intentionally redundant: it protects the integration boundary where the current bug occurred. + +- [ ] **Step 2: Add a thinking regression** + +Add a test with `ThinkingPart(content="let me think")` in the PydanticAI response, then persist the resulting turn and assert a `thinking` context event appears before `assistant_text`: + +```python +assert [event.event_type for event in events] == ["user_message", "thinking", "assistant_text"] +``` + +- [ ] **Step 3: Run the combined tests** + +Run: + +```bash +pytest tests/unit/builtins/common/test_transcript_adapters.py tests/unit/persistence/test_context_event_repository.py -q +``` + +Expected: PASS. + +--- + +## Task 6: Verification Against a Real LLM Smoke Run + +**Files:** + +- No code changes required. +- Optional inspection command only. + +- [ ] **Step 1: Run a small real LLM benchmark using a reasoning-capable model** + +Use the repo's existing real-LLM harness or CLI with a cheap one-task run. Prefer a model target already used by the repo, such as: + +```bash +pytest tests/real_llm/benchmarks/test_researchrubrics.py -q +``` + +If the real-LLM test is intentionally skipped because credentials or budget are unavailable, record that skip in the implementation summary. + +- [ ] **Step 2: Inspect the run snapshot for richer context events** + +For a known run id, inspect event counts: + +```bash +RUN_ID= python - <<'PY' +import json, urllib.request +import os + +run_id = os.environ["RUN_ID"] +with urllib.request.urlopen(f"http://127.0.0.1:3002/api/runs/{run_id}", timeout=5) as r: + data = json.load(r) + +counts = {} +for events in (data.get("contextEventsByTask") or {}).values(): + for event in events: + counts[event.get("eventType")] = counts.get(event.get("eventType"), 0) + 1 + +print(counts) +PY +``` + +Expected for a tool-using run: `tool_result` count is non-zero. Expected for a provider/model that returns thinking: `thinking` count is non-zero. + +Do not fail the implementation if `thinking` is zero for a provider that does not return thoughts despite the request. Do fail if tool-using ReAct runs still have zero `tool_result` events. + +--- + +## Task 7: Final Test Pass + +**Files:** + +- No code changes unless tests reveal a regression. + +- [ ] **Step 1: Run focused backend tests** + +Run: + +```bash +pytest tests/unit/builtins/common/test_capture_settings.py tests/unit/builtins/common/test_transcript_adapters.py tests/unit/persistence/test_context_event_repository.py tests/unit/state/test_generation_turn_build.py tests/unit/workers/test_react_worker_contract.py -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run lints for edited Python files** + +Run the repo's standard Python lint/type command if available. If the repo does not expose a single lint command, at minimum run: + +```bash +python -m compileall ergon_builtins/ergon_builtins/common ergon_builtins/ergon_builtins/workers/baselines ergon_core/ergon_core/core/persistence/context +``` + +Expected: PASS. + +- [ ] **Step 3: Record implementation notes** + +In the implementation summary, include: + +- Whether `tool_result` is now persisted from `GenerationTurn.tool_results`. +- Which provider settings were added for thinking/reasoning. +- Whether real-LLM verification produced `thinking` events or only verified `tool_result`. +- Any provider-specific caveat, especially Anthropic thinking plus structured output behavior. + +--- + +## Acceptance Criteria + +- ReAct worker no longer owns PydanticAI message parsing internals. +- PydanticAI transcript extraction is reusable by other PydanticAI-based workers. +- Real ReAct workers pass capture-oriented model settings when the provider supports thinking/reasoning/logprobs. +- `ContextEventRepository.persist_turn` writes `tool_result` rows from `GenerationTurn.tool_results`. +- A tool-using ReAct run can be inspected through `GET /api/runs/{run_id}` and shows non-zero `tool_result` events. +- Thinking blocks are persisted as `thinking` events when the provider returns PydanticAI `ThinkingPart` objects. + diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py index 94464e41..bd0f1cfb 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py @@ -3,7 +3,7 @@ from ergon_core.api.criterion import Criterion from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.api.results import CriterionResult -from ergon_core.core.providers.generation.structured_judge import ( +from ergon_builtins.common.llm.structured_judge import ( JudgeMessage, call_structured_judge, ) diff --git a/ergon_builtins/ergon_builtins/common/__init__.py b/ergon_builtins/ergon_builtins/common/__init__.py new file mode 100644 index 00000000..2d9969cf --- /dev/null +++ b/ergon_builtins/ergon_builtins/common/__init__.py @@ -0,0 +1 @@ +"""Shared utilities for built-in Ergon components.""" diff --git a/ergon_builtins/ergon_builtins/common/llm/__init__.py b/ergon_builtins/ergon_builtins/common/llm/__init__.py new file mode 100644 index 00000000..9bbad4b7 --- /dev/null +++ b/ergon_builtins/ergon_builtins/common/llm/__init__.py @@ -0,0 +1 @@ +"""Shared LLM helpers for built-in evaluators and workers.""" diff --git a/ergon_core/ergon_core/core/providers/generation/structured_judge.py b/ergon_builtins/ergon_builtins/common/llm/structured_judge.py similarity index 72% rename from ergon_core/ergon_core/core/providers/generation/structured_judge.py rename to ergon_builtins/ergon_builtins/common/llm/structured_judge.py index 769e0734..b383841a 100644 --- a/ergon_core/ergon_core/core/providers/generation/structured_judge.py +++ b/ergon_builtins/ergon_builtins/common/llm/structured_judge.py @@ -1,7 +1,9 @@ +"""Structured LLM judge helper for built-in evaluators.""" + from collections.abc import Sequence from typing import Literal, TypeVar, cast -from ergon_core.core.providers.generation.model_resolution import resolve_model_target +from ergon_builtins.models.resolution import resolve_model_target from pydantic import BaseModel from pydantic_ai import Agent @@ -21,13 +23,7 @@ async def call_structured_judge( response_type: type[T], model: str | None, ) -> T: - """Call an LLM and parse a structured judge response. - - This helper owns only provider mechanics: model resolution, pydantic-ai - invocation, and output parsing. Benchmark criteria own the judge prompts, - user-message formatting, and scoring policy. - """ - + """Call an LLM and parse a structured judge response.""" resolved = resolve_model_target(model) instructions = "\n\n".join(message.content for message in messages if message.role == "system") prompt = "\n\n".join( diff --git a/ergon_builtins/ergon_builtins/common/llm_context/__init__.py b/ergon_builtins/ergon_builtins/common/llm_context/__init__.py new file mode 100644 index 00000000..9173f54d --- /dev/null +++ b/ergon_builtins/ergon_builtins/common/llm_context/__init__.py @@ -0,0 +1 @@ +"""Helpers for capturing and replaying LLM context in built-in workers.""" diff --git a/ergon_builtins/ergon_builtins/common/llm_context/adapters/__init__.py b/ergon_builtins/ergon_builtins/common/llm_context/adapters/__init__.py new file mode 100644 index 00000000..3a1146b0 --- /dev/null +++ b/ergon_builtins/ergon_builtins/common/llm_context/adapters/__init__.py @@ -0,0 +1 @@ +"""Framework adapters for LLM transcript extraction and replay assembly.""" diff --git a/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py b/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py new file mode 100644 index 00000000..35e70575 --- /dev/null +++ b/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py @@ -0,0 +1,21 @@ +"""Base interface for framework transcript adapters.""" + +from typing import Protocol, TypeVar + +from ergon_core.api.generation import GenerationTurn +from ergon_core.core.persistence.context.models import RunContextEvent + +TranscriptT = TypeVar("TranscriptT") +ReplayT = TypeVar("ReplayT") + + +class TranscriptAdapter(Protocol[TranscriptT, ReplayT]): + """Convert between framework-native transcripts and Ergon context events.""" + + def build_turns(self, transcript: TranscriptT) -> list[GenerationTurn]: + """Return ordered turns extracted from a complete transcript.""" + ... + + def assemble_replay(self, events: list[RunContextEvent]) -> ReplayT: + """Return framework-native replay context from ordered context events.""" + ... diff --git a/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py b/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py new file mode 100644 index 00000000..c5292c09 --- /dev/null +++ b/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py @@ -0,0 +1,221 @@ +"""PydanticAI transcript adapter.""" + +import json +from ergon_core.api.generation import ( + GenerationTurn, + ModelRequestPart as ErgonModelRequestPart, + ModelResponsePart as ErgonModelResponsePart, + SystemPromptPart, + TextPart, + ThinkingPart, + TokenLogprob, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) +from ergon_core.core.persistence.context.event_payloads import ( + AssistantTextPayload, + SystemPromptPayload, + ThinkingPayload, + ToolCallPayload, + ToolResultPayload, + UserMessagePayload, +) +from ergon_core.core.persistence.context.models import RunContextEvent +from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse +from pydantic_ai.messages import ModelRequestPart as PydanticModelRequestPart +from pydantic_ai.messages import ModelResponsePart as PydanticModelResponsePart +from pydantic_ai.messages import SystemPromptPart as PydanticSystemPromptPart +from pydantic_ai.messages import TextPart as PydanticTextPart +from pydantic_ai.messages import ThinkingPart as PydanticThinkingPart +from pydantic_ai.messages import ToolCallPart as PydanticToolCallPart +from pydantic_ai.messages import ToolReturnContent +from pydantic_ai.messages import ToolReturnPart as PydanticToolReturnPart +from pydantic_ai.messages import UserPromptPart as PydanticUserPromptPart +from pydantic_core import to_jsonable_python + +from ergon_builtins.common.llm_context.adapters.base import TranscriptAdapter + + +class PydanticAITranscriptAdapter(TranscriptAdapter[list[ModelMessage], list[ModelMessage]]): + """Convert complete PydanticAI message histories into Ergon turns.""" + + def build_turns(self, transcript: list[ModelMessage]) -> list[GenerationTurn]: + """Build turns from a complete PydanticAI message list.""" + turns: list[GenerationTurn] = [] + pending_response: ModelResponse | None = None + pending_request_in: ModelRequest | None = None + + for message in transcript: + if isinstance(message, ModelRequest): + if pending_response is not None: + turns.append( + _to_turn( + pending_request_in, + pending_response, + tool_result_request=message, + ) + ) + pending_response = None + pending_request_in = None + pending_request_in = message + elif isinstance(message, ModelResponse): + pending_response = message + + if pending_response is not None: + turns.append(_to_turn(pending_request_in, pending_response, tool_result_request=None)) + + return turns + + def assemble_replay(self, events: list[RunContextEvent]) -> list[ModelMessage]: + """Reconstruct PydanticAI messages from ordered context events.""" + messages: list[ModelMessage] = [] + current_request_parts: list[PydanticModelRequestPart] = [] + current_response_parts: list[PydanticModelResponsePart] = [] + + for event in events: + payload = event.parsed_payload() + if request_part := _to_pydantic_request_part(payload): + if isinstance(payload, ToolResultPayload) and current_response_parts: + messages.append(ModelResponse(parts=current_response_parts)) + current_response_parts = [] + current_request_parts.append(request_part) + elif response_part := _to_pydantic_response_part(payload): + if current_request_parts and not current_response_parts: + messages.append(ModelRequest(parts=current_request_parts)) + current_request_parts = [] + current_response_parts.append(response_part) + + if current_response_parts: + messages.append(ModelResponse(parts=current_response_parts)) + + return messages + + +def _to_turn( + request_in: ModelRequest | None, + response: ModelResponse, + tool_result_request: ModelRequest | None, +) -> GenerationTurn: + return GenerationTurn( + messages_in=_extract_request_parts(request_in) if request_in else [], + response_parts=_extract_response_parts(response), + tool_results=_extract_tool_results(tool_result_request) if tool_result_request else [], + turn_logprobs=extract_logprobs(response), + ) + + +def extract_logprobs(response: ModelResponse) -> list[TokenLogprob] | None: + """Extract per-token logprobs from PydanticAI provider metadata.""" + details = response.provider_details + if details is None: + return None + raw_logprobs = details.get("logprobs") + if not isinstance(raw_logprobs, list) or not raw_logprobs: + return None + logprobs: list[TokenLogprob] = [] + for entry in raw_logprobs: + if not isinstance(entry, dict): + continue + token = entry.get("token") + logprob = entry.get("logprob") + top_logprobs = entry.get("top_logprobs", []) + if isinstance(token, str) and isinstance(logprob, int | float) and isinstance(top_logprobs, list): + logprobs.append( + TokenLogprob( + token=token, + logprob=float(logprob), + top_logprobs=[item for item in top_logprobs if isinstance(item, dict)], + ) + ) + return logprobs or None + + +def _extract_request_parts(request: ModelRequest) -> list[ErgonModelRequestPart]: + parts: list[ErgonModelRequestPart] = [] + for part in request.parts: + if isinstance(part, PydanticSystemPromptPart): + parts.append(SystemPromptPart(content=part.content)) + elif isinstance(part, PydanticUserPromptPart) and isinstance(part.content, str): + parts.append(UserPromptPart(content=part.content)) + return parts + + +def _extract_response_parts(response: ModelResponse) -> list[ErgonModelResponsePart]: + parts: list[ErgonModelResponsePart] = [] + for part in response.parts: + if isinstance(part, PydanticTextPart): + parts.append(TextPart(content=part.content)) + elif isinstance(part, PydanticToolCallPart): + parts.append( + ToolCallPart( + tool_name=part.tool_name, + tool_call_id=part.tool_call_id, + args=part.args_as_dict(), + ) + ) + elif isinstance(part, PydanticThinkingPart): + parts.append(ThinkingPart(content=part.content)) + return parts + + +def _extract_tool_results(request: ModelRequest) -> list[ToolReturnPart]: + results: list[ToolReturnPart] = [] + for part in request.parts: + if isinstance(part, PydanticToolReturnPart): + results.append( + ToolReturnPart( + tool_call_id=part.tool_call_id, + tool_name=part.tool_name, + content=_serialize_tool_content(part.content), + ) + ) + return results + + +def _serialize_tool_content(content: ToolReturnContent) -> str: + if isinstance(content, str): + return content + return json.dumps(to_jsonable_python(content)) + + +def _to_pydantic_response_part( + payload: AssistantTextPayload + | ThinkingPayload + | ToolCallPayload + | SystemPromptPayload + | UserMessagePayload + | ToolResultPayload, +) -> PydanticModelResponsePart | None: + if isinstance(payload, ThinkingPayload): + return PydanticThinkingPart(content=payload.text) + if isinstance(payload, AssistantTextPayload): + return PydanticTextPart(content=payload.text) + if isinstance(payload, ToolCallPayload): + return PydanticToolCallPart( + tool_name=payload.tool_name, + tool_call_id=payload.tool_call_id, + args=payload.args, + ) + return None + + +def _to_pydantic_request_part( + payload: AssistantTextPayload + | ThinkingPayload + | ToolCallPayload + | SystemPromptPayload + | UserMessagePayload + | ToolResultPayload, +) -> PydanticModelRequestPart | None: + if isinstance(payload, SystemPromptPayload): + return PydanticSystemPromptPart(content=payload.text) + if isinstance(payload, UserMessagePayload): + return PydanticUserPromptPart(content=payload.text) + if isinstance(payload, ToolResultPayload): + return PydanticToolReturnPart( + tool_call_id=payload.tool_call_id, + tool_name=payload.tool_name, + content=str(payload.result), + ) + return None diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py index 2afe2fb4..f119be21 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py @@ -11,7 +11,7 @@ from ergon_core.api.criterion import Criterion from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.api.results import CriterionResult -from ergon_core.core.providers.generation.structured_judge import ( +from ergon_builtins.common.llm.structured_judge import ( JudgeMessage, call_structured_judge, ) diff --git a/ergon_builtins/ergon_builtins/models/cloud_passthrough.py b/ergon_builtins/ergon_builtins/models/cloud_passthrough.py index e7620a1d..44096c2c 100644 --- a/ergon_builtins/ergon_builtins/models/cloud_passthrough.py +++ b/ergon_builtins/ergon_builtins/models/cloud_passthrough.py @@ -1,6 +1,6 @@ """Cloud passthrough: resolves ``openai:``, ``anthropic:``, etc. by passing through to PydanticAI.""" -from ergon_core.core.providers.generation.model_resolution import ResolvedModel +from ergon_builtins.models.resolution import ResolvedModel def resolve_cloud( diff --git a/ergon_builtins/ergon_builtins/models/openrouter_backend.py b/ergon_builtins/ergon_builtins/models/openrouter_backend.py index 5ec1d3e5..ac91390d 100644 --- a/ergon_builtins/ergon_builtins/models/openrouter_backend.py +++ b/ergon_builtins/ergon_builtins/models/openrouter_backend.py @@ -2,7 +2,7 @@ import logging -from ergon_core.core.providers.generation.model_resolution import ResolvedModel +from ergon_builtins.models.resolution import ResolvedModel from ergon_core.core.settings import settings from pydantic_ai.models.openrouter import OpenRouterModel, OpenRouterProvider diff --git a/ergon_builtins/ergon_builtins/models/resolution.py b/ergon_builtins/ergon_builtins/models/resolution.py new file mode 100644 index 00000000..ba2e0492 --- /dev/null +++ b/ergon_builtins/ergon_builtins/models/resolution.py @@ -0,0 +1,110 @@ +"""Prefix-based model target resolution for built-in PydanticAI backends.""" + +import logging +from collections.abc import Callable + +from ergon_core.api.json_types import JsonObject +import pydantic_ai.models +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +_ANTHROPIC_THINKING_BUDGET_TOKENS = 1024 +_OPENAI_COMPAT_LOGPROB_SETTINGS: JsonObject = { + "openai_logprobs": True, + "openai_top_logprobs": 1, +} + + +class ResolvedModel(BaseModel): + """A resolved model target with backend metadata.""" + + model_config = {"frozen": True, "arbitrary_types_allowed": True} + + model: pydantic_ai.models.Model | str + policy_version: str | None = None + supports_logprobs: bool = False + capture_model_settings: JsonObject | None = None + + +_BACKEND_REGISTRY: dict[str, Callable[..., ResolvedModel]] = {} + + +def register_model_backend(prefix: str, resolver: Callable[..., ResolvedModel]) -> None: + """Register a model backend resolver for a given target prefix.""" + _BACKEND_REGISTRY[prefix] = resolver + + +def _target_prefix(model_target: str | None) -> str: + target = model_target or "" + return target.split(":", 1)[0] if ":" in target else "" + + +def capture_model_settings_for( + model_target: str | None, + *, + supports_logprobs: bool = False, +) -> JsonObject | None: + """Return PydanticAI model settings for richer transcript capture.""" + prefix = _target_prefix(model_target) + + if prefix == "vllm" and supports_logprobs: + return dict(_OPENAI_COMPAT_LOGPROB_SETTINGS) + + if prefix == "anthropic": + return { + "anthropic_thinking": { + "type": "enabled", + "budget_tokens": _ANTHROPIC_THINKING_BUDGET_TOKENS, + } + } + + if prefix == "openrouter": + return { + "openrouter_reasoning": { + "enabled": True, + "exclude": False, + } + } + + if prefix == "google": + return { + "gemini_thinking_config": { + "include_thoughts": True, + } + } + + return None + + +def _with_capture_settings(target: str, resolved: ResolvedModel) -> ResolvedModel: + settings = capture_model_settings_for(target, supports_logprobs=resolved.supports_logprobs) + if resolved.capture_model_settings == settings: + return resolved + return resolved.model_copy(update={"capture_model_settings": settings}) + + +def resolve_model_target( + model_target: str | None, + *, + model_name: str | None = None, + policy_version: str | None = None, + api_key: str | None = None, +) -> ResolvedModel: + """Resolve a model target string to a PydanticAI-compatible model.""" + target = model_target or "openai:gpt-4o" + prefix = _target_prefix(target) + + resolver = _BACKEND_REGISTRY.get(prefix) + if resolver is not None: + return _with_capture_settings( + target, + resolver( + target, + model_name=model_name, + policy_version=policy_version, + api_key=api_key, + ), + ) + + return _with_capture_settings(target, ResolvedModel(model=target, supports_logprobs=False)) diff --git a/ergon_builtins/ergon_builtins/models/transformers_backend.py b/ergon_builtins/ergon_builtins/models/transformers_backend.py index 42f038a9..2f99011d 100644 --- a/ergon_builtins/ergon_builtins/models/transformers_backend.py +++ b/ergon_builtins/ergon_builtins/models/transformers_backend.py @@ -16,7 +16,7 @@ import pydantic_ai.models as _models import torch from transformers import AutoModelForCausalLM, AutoTokenizer -from ergon_core.core.providers.generation.model_resolution import ResolvedModel +from ergon_builtins.models.resolution import ResolvedModel from pydantic_ai.settings import ModelSettings logger = logging.getLogger(__name__) diff --git a/ergon_builtins/ergon_builtins/models/vllm_backend.py b/ergon_builtins/ergon_builtins/models/vllm_backend.py index 0488f5a1..720ee83d 100644 --- a/ergon_builtins/ergon_builtins/models/vllm_backend.py +++ b/ergon_builtins/ergon_builtins/models/vllm_backend.py @@ -5,7 +5,7 @@ import urllib.error import urllib.request -from ergon_core.core.providers.generation.model_resolution import ResolvedModel +from ergon_builtins.models.resolution import ResolvedModel from pydantic_ai.models.openai import OpenAIModel as OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider diff --git a/ergon_builtins/ergon_builtins/registry.py b/ergon_builtins/ergon_builtins/registry.py index aa340e2f..0a6905fb 100644 --- a/ergon_builtins/ergon_builtins/registry.py +++ b/ergon_builtins/ergon_builtins/registry.py @@ -8,7 +8,7 @@ import structlog from ergon_core.api import Benchmark, Evaluator, Worker -from ergon_core.core.providers.generation.model_resolution import ( +from ergon_builtins.models.resolution import ( ResolvedModel, register_model_backend, ) diff --git a/ergon_builtins/ergon_builtins/registry_core.py b/ergon_builtins/ergon_builtins/registry_core.py index 4d1f98d9..78288c90 100644 --- a/ergon_builtins/ergon_builtins/registry_core.py +++ b/ergon_builtins/ergon_builtins/registry_core.py @@ -10,7 +10,7 @@ from uuid import UUID from ergon_core.api import Benchmark, Evaluator, Worker -from ergon_core.core.providers.generation.model_resolution import ResolvedModel +from ergon_builtins.models.resolution import ResolvedModel from ergon_core.core.providers.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.gdpeval.rubric import StagedRubric diff --git a/ergon_builtins/ergon_builtins/registry_local_models.py b/ergon_builtins/ergon_builtins/registry_local_models.py index e45abd5d..15fec1aa 100644 --- a/ergon_builtins/ergon_builtins/registry_local_models.py +++ b/ergon_builtins/ergon_builtins/registry_local_models.py @@ -7,7 +7,7 @@ from collections.abc import Callable -from ergon_core.core.providers.generation.model_resolution import ResolvedModel +from ergon_builtins.models.resolution import ResolvedModel from ergon_builtins.models.transformers_backend import resolve_transformers diff --git a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py index 04a104f5..f53efaa7 100644 --- a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py @@ -1,56 +1,22 @@ # ergon_builtins/ergon_builtins/workers/baselines/react_worker.py """ReAct-style worker using pydantic-ai Agent for tool-augmented execution.""" -import dataclasses # slopcop: ignore[no-dataclass] import json import logging from collections.abc import AsyncGenerator from typing import Any, Self from uuid import UUID +from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter +from ergon_builtins.models.resolution import resolve_model_target from ergon_core.api import BenchmarkTask, Tool, Worker, WorkerContext, WorkerOutput -from ergon_core.api.generation import ( - GenerationTurn, - SystemPromptPart, - TextPart, - ThinkingPart, - ToolCallPart, - ToolReturnPart, - UserPromptPart, -) -from ergon_core.api.json_types import JsonObject -from ergon_core.core.persistence.context.assembly import assemble_pydantic_ai_messages +from ergon_core.api.generation import GenerationTurn from ergon_core.core.persistence.context.repository import ContextEventRepository from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.providers.generation.model_resolution import resolve_model_target -from ergon_core.core.providers.generation.pydantic_ai_format import extract_logprobs -from ergon_core.core.rl import LOGPROB_SETTINGS from pydantic import BaseModel from pydantic_ai import Agent -from pydantic_ai.messages import ( - ModelMessage, - ModelRequest, - ModelResponse, -) -from pydantic_ai.messages import ( - SystemPromptPart as PydanticSystemPromptPart, -) -from pydantic_ai.messages import ( - TextPart as PydanticTextPart, -) -from pydantic_ai.messages import ( - ThinkingPart as PydanticThinkingPart, -) -from pydantic_ai.messages import ( - ToolCallPart as PydanticToolCallPart, -) -from pydantic_ai.messages import ( - ToolReturnPart as PydanticToolReturnPart, -) -from pydantic_ai.messages import ( - UserPromptPart as PydanticUserPromptPart, -) +from pydantic_ai.messages import ModelMessage from sqlmodel import Session logger = logging.getLogger(__name__) @@ -110,10 +76,6 @@ async def _run_agent( """Run the underlying pydantic-ai agent and yield the turns it produced.""" resolved = resolve_model_target(self.model) - model_settings: JsonObject | None = None - if resolved.supports_logprobs and self.model and self.model.startswith("vllm:"): - model_settings = LOGPROB_SETTINGS - agent: Agent[None, _AgentOutput] = Agent( model=resolved.model, instructions=self.system_prompt or None, @@ -126,7 +88,7 @@ async def _run_agent( async with agent.iter( task_prompt, - model_settings=model_settings, + model_settings=resolved.capture_model_settings, message_history=self._seed_messages, ) as run: async for _node in run: @@ -144,7 +106,7 @@ async def _run_agent( # Works for both complete and partial (max_iterations) runs — # pydantic-ai 0.7.x moved all_messages() to AgentRunResult, but # ctx.state.message_history is always populated incrementally. - turns = _build_turns(run.ctx.state.message_history) + turns = PydanticAITranscriptAdapter().build_turns(run.ctx.state.message_history) for turn in turns: yield turn @@ -202,15 +164,10 @@ def from_buffer( if not events: return None worker = cls(**kwargs) - worker._seed_messages = assemble_pydantic_ai_messages(events) + worker._seed_messages = PydanticAITranscriptAdapter().assemble_replay(events) return worker -# --------------------------------------------------------------------------- -# PydanticAI message → GenerationTurn -# --------------------------------------------------------------------------- - - def _format_task(task: BenchmarkTask) -> str: lines = [f"Task: {task.description}"] payload = task.task_payload.model_dump(mode="json") @@ -238,110 +195,3 @@ def _latest_final_result_message( continue messages.append(str(payload.args.get("final_assistant_message", ""))) return messages[-1] if messages else "" - - -def _build_turns(messages: list[ModelMessage]) -> list[GenerationTurn]: - """Build GenerationTurn objects from a complete PydanticAI message list. - - Caller must pass the full message history — NOT incremental slices. - Using incremental slices causes tool_results to always be empty because - ToolReturnParts appear in the *next* ModelRequest, which is not in the slice. - """ - turns: list[GenerationTurn] = [] - pending_response: ModelResponse | None = None - pending_request_in: ModelRequest | None = None - - for message in messages: - if isinstance(message, ModelRequest): - if pending_response is not None: - turns.append( - _to_turn( - pending_request_in, - pending_response, - tool_result_request=message, - ) - ) - pending_response = None - pending_request_in = None - pending_request_in = message - elif isinstance(message, ModelResponse): - pending_response = message - - if pending_response is not None: - turns.append(_to_turn(pending_request_in, pending_response, tool_result_request=None)) - - return turns - - -def _to_turn( - request_in: ModelRequest | None, - response: ModelResponse, - tool_result_request: ModelRequest | None, -) -> GenerationTurn: - raw_resp = _make_json_safe(dataclasses.asdict(response)) - return GenerationTurn( - messages_in=_extract_request_parts(request_in) if request_in else [], - response_parts=_extract_response_parts(response), - tool_results=_extract_tool_results(tool_result_request) if tool_result_request else [], - turn_logprobs=extract_logprobs(raw_resp), - ) - - -def _extract_request_parts(request: ModelRequest) -> list[Any]: # slopcop: ignore[no-typing-any] - parts: list[Any] = [] # slopcop: ignore[no-typing-any] - for part in request.parts: - if isinstance(part, PydanticSystemPromptPart): - parts.append(SystemPromptPart(content=part.content)) - elif isinstance(part, PydanticUserPromptPart) and isinstance(part.content, str): - parts.append(UserPromptPart(content=part.content)) - # ToolReturnParts are extracted separately as tool_results — skip here - return parts - - -def _extract_response_parts(response: ModelResponse) -> list[Any]: # slopcop: ignore[no-typing-any] - parts: list[Any] = [] # slopcop: ignore[no-typing-any] - for part in response.parts: - if isinstance(part, PydanticTextPart): - parts.append(TextPart(content=part.content)) - elif isinstance(part, PydanticToolCallPart): - parts.append( - ToolCallPart( - tool_name=part.tool_name, - tool_call_id=part.tool_call_id, - args=part.args_as_dict(), - ) - ) - elif isinstance(part, PydanticThinkingPart): - parts.append(ThinkingPart(content=part.content)) - return parts - - -def _extract_tool_results(request: ModelRequest) -> list[ToolReturnPart]: - results: list[ToolReturnPart] = [] - for part in request.parts: - if isinstance(part, PydanticToolReturnPart): - content = part.content - serialized = content if isinstance(content, str) else json.dumps(content, default=str) - results.append( - ToolReturnPart( - tool_call_id=part.tool_call_id, - tool_name=part.tool_name, - content=serialized, - ) - ) - return results - - -def _make_json_safe(obj: Any) -> Any: # slopcop: ignore[no-typing-any] - # reason: avoid polluting module namespace with stdlib datetime - from datetime import datetime - - if isinstance(obj, dict): - return {k: _make_json_safe(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_make_json_safe(v) for v in obj] - if isinstance(obj, datetime): - return obj.isoformat() - if isinstance(obj, bytes): - return obj.decode("utf-8", errors="replace") - return obj diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/_run_skill.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/_run_skill.py index 669fba6e..96a55aee 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/_run_skill.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/_run_skill.py @@ -25,7 +25,7 @@ from types import UnionType from typing import ClassVar, Literal, Protocol, cast, get_args, get_type_hints -from ergon_core.core.providers.generation.model_resolution import resolve_model_target +from ergon_builtins.models.resolution import resolve_model_target from pydantic import BaseModel from pydantic_ai import Agent diff --git a/ergon_core/ergon_core/core/persistence/context/assembly.py b/ergon_core/ergon_core/core/persistence/context/assembly.py deleted file mode 100644 index 66ab1a8f..00000000 --- a/ergon_core/ergon_core/core/persistence/context/assembly.py +++ /dev/null @@ -1,127 +0,0 @@ -# ergon_core/ergon_core/core/persistence/context/assembly.py -"""Reconstruct PydanticAI message history from stored context events. - -Used by ReActWorker.from_buffer() to resume a paused execution. -Events must be pre-sorted by sequence (ascending). -""" - -from ergon_core.core.persistence.context.event_payloads import ( - AssistantTextPayload, - SystemPromptPayload, - ThinkingPayload, - ToolCallPayload, - ToolResultPayload, - UserMessagePayload, -) -from ergon_core.core.persistence.context.models import RunContextEvent -from pydantic_ai.messages import ( - ModelMessage, - ModelRequest, - ModelResponse, -) -from pydantic_ai.messages import ( - SystemPromptPart as PydanticSystemPromptPart, -) -from pydantic_ai.messages import ( - TextPart as PydanticTextPart, -) -from pydantic_ai.messages import ( - ThinkingPart as PydanticThinkingPart, -) -from pydantic_ai.messages import ( - ToolCallPart as PydanticToolCallPart, -) -from pydantic_ai.messages import ( - ToolReturnPart as PydanticToolReturnPart, -) -from pydantic_ai.messages import ( - UserPromptPart as PydanticUserPromptPart, -) - - -def _to_response_part(event: RunContextEvent): - """Convert a model-output event to its PydanticAI response part.""" - parsed = event.parsed_payload() - if event.event_type == "thinking": - if not isinstance(parsed, ThinkingPayload): - raise ValueError(f"Expected ThinkingPayload for thinking event, got {type(parsed)}") - return PydanticThinkingPart(content=parsed.text) - if event.event_type == "assistant_text": - if not isinstance(parsed, AssistantTextPayload): - raise ValueError( - f"Expected AssistantTextPayload for assistant_text event, got {type(parsed)}" - ) - return PydanticTextPart(content=parsed.text) - if event.event_type == "tool_call": - if not isinstance(parsed, ToolCallPayload): - raise ValueError(f"Expected ToolCallPayload for tool_call event, got {type(parsed)}") - return PydanticToolCallPart( - tool_name=parsed.tool_name, - tool_call_id=parsed.tool_call_id, - args=parsed.args, - ) - raise ValueError(f"Unexpected response event_type: {event.event_type!r}") - - -def _to_request_part(event: RunContextEvent): - """Convert a request-side event to its PydanticAI request part.""" - parsed = event.parsed_payload() - if event.event_type == "system_prompt": - if not isinstance(parsed, SystemPromptPayload): - raise ValueError( - f"Expected SystemPromptPayload for system_prompt event, got {type(parsed)}" - ) - return PydanticSystemPromptPart(content=parsed.text) - if event.event_type == "user_message": - if not isinstance(parsed, UserMessagePayload): - raise ValueError( - f"Expected UserMessagePayload for user_message event, got {type(parsed)}" - ) - return PydanticUserPromptPart(content=parsed.text) - if event.event_type == "tool_result": - if not isinstance(parsed, ToolResultPayload): - raise ValueError( - f"Expected ToolResultPayload for tool_result event, got {type(parsed)}" - ) - return PydanticToolReturnPart( - tool_call_id=parsed.tool_call_id, - tool_name=parsed.tool_name, - content=str(parsed.result), - ) - raise ValueError(f"Unexpected request event_type: {event.event_type!r}") - - -def assemble_pydantic_ai_messages(events: list[RunContextEvent]) -> list[ModelMessage]: - """Reconstruct the alternating ModelRequest / ModelResponse sequence. - - Grouping rules: - - system_prompt / user_message → parts of the leading ModelRequest - - thinking / assistant_text / tool_call → parts of the current ModelResponse - - tool_result → closes the current ModelResponse, opens a new ModelRequest - - Trailing response (no subsequent tool_result) is flushed at end. - """ - messages: list[ModelMessage] = [] - current_request_parts: list = [] - current_response_parts: list = [] - - for event in events: - if event.event_type in ("system_prompt", "user_message"): - current_request_parts.append(_to_request_part(event)) - - elif event.event_type in ("thinking", "assistant_text", "tool_call"): - # First model-generated event: flush the pending request - if current_request_parts and not current_response_parts: - messages.append(ModelRequest(parts=current_request_parts)) - current_request_parts = [] - current_response_parts.append(_to_response_part(event)) - - elif event.event_type == "tool_result": - if current_response_parts: - messages.append(ModelResponse(parts=current_response_parts)) - current_response_parts = [] - current_request_parts.append(_to_request_part(event)) - - if current_response_parts: - messages.append(ModelResponse(parts=current_response_parts)) - - return messages diff --git a/ergon_core/ergon_core/core/persistence/context/repository.py b/ergon_core/ergon_core/core/persistence/context/repository.py index fade9689..83fec7e5 100644 --- a/ergon_core/ergon_core/core/persistence/context/repository.py +++ b/ergon_core/ergon_core/core/persistence/context/repository.py @@ -171,25 +171,27 @@ def _events_from_tool_results( turn: GenerationTurn, seq: int, ) -> tuple[list[RunContextEvent], int]: - """Produce tool_result events from ToolReturnParts in messages_in.""" + """Produce tool_result events from GenerationTurn tool observations.""" events: list[RunContextEvent] = [] - for part in turn.messages_in: - if isinstance(part, ToolReturnPart): - events.append( - self._make_event( - run_id, - execution_id, - worker_binding_key, - seq, - ToolResultPayload( - tool_call_id=part.tool_call_id, - tool_name=part.tool_name, - result=part.content, - # Set is_error=True when ToolReturnPart gains an is_error field (currently always False) - ), - ) + tool_result_parts = turn.tool_results or [ + part for part in turn.messages_in if isinstance(part, ToolReturnPart) + ] + for part in tool_result_parts: + events.append( + self._make_event( + run_id, + execution_id, + worker_binding_key, + seq, + ToolResultPayload( + tool_call_id=part.tool_call_id, + tool_name=part.tool_name, + result=part.content, + # TODO: Set is_error=True when ToolReturnPart gains an is_error field (currently always False) + ), ) - seq += 1 + ) + seq += 1 return events, seq async def persist_turn( diff --git a/ergon_core/ergon_core/core/providers/generation/__init__.py b/ergon_core/ergon_core/core/providers/generation/__init__.py index 585bef15..765985ec 100644 --- a/ergon_core/ergon_core/core/providers/generation/__init__.py +++ b/ergon_core/ergon_core/core/providers/generation/__init__.py @@ -1,9 +1,4 @@ -"""Generation provider helpers for model-specific integrations.""" +"""Generation provider namespace. -from ergon_core.core.providers.generation.model_resolution import ( - ResolvedModel, - register_model_backend, - resolve_model_target, -) - -__all__ = ["ResolvedModel", "register_model_backend", "resolve_model_target"] +Concrete PydanticAI model resolution lives in ``ergon_builtins.models``. +""" diff --git a/ergon_core/ergon_core/core/providers/generation/model_resolution.py b/ergon_core/ergon_core/core/providers/generation/model_resolution.py deleted file mode 100644 index 93f312df..00000000 --- a/ergon_core/ergon_core/core/providers/generation/model_resolution.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Prefix-based model target resolution. - -Dispatches ``model_target`` strings to the appropriate backend based on -their prefix (``vllm:``, ``transformers:``, ``openai:``, etc.). - -Concrete backend implementations live in ``ergon_builtins.models``. -This module owns the contract (``ResolvedModel``) and the dispatch logic. -""" - -import logging -from typing import Callable - -import pydantic_ai.models -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - - -class ResolvedModel(BaseModel): - """A resolved model target with backend metadata. - - Workers pass ``.model`` to ``Agent(model=...)``, read - ``.policy_version`` for provenance metadata, and check - ``.supports_logprobs`` to decide whether to expect per-token - logprob data in the response. - """ - - model_config = {"frozen": True, "arbitrary_types_allowed": True} - - model: pydantic_ai.models.Model | str - policy_version: str | None = None - supports_logprobs: bool = False - - -# Backend resolver registry: prefix -> callable -# Populated by ergon_builtins.registry at import time. -_BACKEND_REGISTRY: dict[str, Callable[..., ResolvedModel]] = {} - - -def register_model_backend(prefix: str, resolver: Callable[..., ResolvedModel]) -> None: - """Register a model backend resolver for a given prefix.""" - _BACKEND_REGISTRY[prefix] = resolver - - -def resolve_model_target( - model_target: str | None, - *, - model_name: str | None = None, - policy_version: str | None = None, - api_key: str | None = None, -) -> ResolvedModel: - """Resolve a ``model_target`` string to a PydanticAI-compatible model. - - Dispatches by prefix to registered backends. Unrecognised prefixes - are passed through to PydanticAI's ``infer_model``. - """ - target = model_target or "openai:gpt-4o" - - prefix = target.split(":")[0] if ":" in target else "" - - resolver = _BACKEND_REGISTRY.get(prefix) - if resolver is not None: - return resolver( - target, - model_name=model_name, - policy_version=policy_version, - api_key=api_key, - ) - - return ResolvedModel(model=target, supports_logprobs=False) diff --git a/ergon_core/ergon_core/core/providers/generation/pydantic_ai_format.py b/ergon_core/ergon_core/core/providers/generation/pydantic_ai_format.py deleted file mode 100644 index 243dc2b7..00000000 --- a/ergon_core/ergon_core/core/providers/generation/pydantic_ai_format.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Single source of truth for parsing PydanticAI's serialised message format. - -PydanticAI serialises ``ModelResponse`` via ``dataclasses.asdict()`` into:: - - { - "parts": [ - {"part_kind": "text", "content": "..."}, - {"part_kind": "tool-call", "tool_call_id": "...", "tool_name": "...", "args": {...}}, - ], - "provider_details": {"logprobs": [{"token": "...", "logprob": -0.1, ...}]}, - ... - } - -All code that needs to read these dumps should call into this module -rather than re-implementing the parsing. -""" - -from ergon_core.api.generation import TokenLogprob -from ergon_core.api.json_types import JsonObject - - -def extract_logprobs( - raw: JsonObject, -) -> list[TokenLogprob] | None: - """Extract per-token logprobs from a PydanticAI response dump. - - PydanticAI stores vLLM logprobs in ``provider_details["logprobs"]``. - Returns None if no logprobs are available (cloud APIs). - """ - details = raw.get("provider_details") - if not isinstance(details, dict): - return None - raw_logprobs = details.get("logprobs") - if not isinstance(raw_logprobs, list) or not raw_logprobs: - return None - return [ - TokenLogprob( - token=entry["token"], - logprob=entry["logprob"], - top_logprobs=entry.get("top_logprobs", []), - ) - for entry in raw_logprobs - if isinstance(entry, dict) and "token" in entry and "logprob" in entry - ] diff --git a/ergon_core/ergon_core/core/rl/__init__.py b/ergon_core/ergon_core/core/rl/__init__.py index 9d0ce551..f8a44ac4 100644 --- a/ergon_core/ergon_core/core/rl/__init__.py +++ b/ergon_core/ergon_core/core/rl/__init__.py @@ -9,19 +9,3 @@ - ``rewards``: reward strategies for per-agent credit assignment - ``rollout_service``: service client for managed rollout execution """ - -from ergon_core.api.json_types import JsonObject - -LOGPROB_SETTINGS: JsonObject = { - "openai_logprobs": True, - "openai_top_logprobs": 1, -} -"""PydanticAI model settings that request logprobs from OpenAI-compatible APIs. - -Only needed for the vLLM backend (which uses the OpenAI API format). -The transformers backend handles logprobs internally via output_logits. - -Pass as ``model_settings`` when running the agent:: - - result = await agent.run(prompt, model_settings=LOGPROB_SETTINGS) -""" diff --git a/pyproject.toml b/pyproject.toml index 158f9aeb..fff065e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,7 +136,7 @@ invalid-assignment = "warn" # pydantic-ai message formatting — complex generic dict unions ty can't resolve. [[tool.ty.overrides]] -include = ["**/providers/generation/pydantic_ai_format.py"] +include = ["**/common/llm_context/adapters/pydantic_ai.py"] [tool.ty.overrides.rules] invalid-argument-type = "warn" unresolved-attribute = "warn" diff --git a/tests/unit/builtins/common/test_capture_settings.py b/tests/unit/builtins/common/test_capture_settings.py new file mode 100644 index 00000000..ed319bfd --- /dev/null +++ b/tests/unit/builtins/common/test_capture_settings.py @@ -0,0 +1,41 @@ +from ergon_builtins.models.resolution import ResolvedModel, capture_model_settings_for + + +def _resolved(*, supports_logprobs: bool = False) -> ResolvedModel: + return ResolvedModel( + model="dummy", + supports_logprobs=supports_logprobs, + capture_model_settings=capture_model_settings_for( + "vllm:http://localhost:8000" if supports_logprobs else "openai:gpt-4o", + supports_logprobs=supports_logprobs, + ), + ) + + +def test_vllm_enables_logprobs() -> None: + assert _resolved(supports_logprobs=True).capture_model_settings == { + "openai_logprobs": True, + "openai_top_logprobs": 1, + } + + +def test_anthropic_enables_thinking() -> None: + assert capture_model_settings_for("anthropic:claude-sonnet-4") == { + "anthropic_thinking": {"type": "enabled", "budget_tokens": 1024}, + } + + +def test_openrouter_includes_reasoning() -> None: + assert capture_model_settings_for("openrouter:anthropic/claude-sonnet-4.6") == { + "openrouter_reasoning": {"enabled": True, "exclude": False}, + } + + +def test_google_includes_thoughts() -> None: + assert capture_model_settings_for("google:gemini-2.5-pro") == { + "gemini_thinking_config": {"include_thoughts": True}, + } + + +def test_unknown_provider_without_capture_returns_none() -> None: + assert capture_model_settings_for("openai:gpt-4o") is None diff --git a/tests/unit/builtins/common/test_transcript_adapters.py b/tests/unit/builtins/common/test_transcript_adapters.py new file mode 100644 index 00000000..1709193f --- /dev/null +++ b/tests/unit/builtins/common/test_transcript_adapters.py @@ -0,0 +1,163 @@ +from uuid import uuid4 + +from ergon_builtins.common.llm_context.adapters.base import TranscriptAdapter +from ergon_builtins.common.llm_context.adapters.pydantic_ai import ( + PydanticAITranscriptAdapter, +) +from ergon_core.api.generation import ( + GenerationTurn, + TextPart as ErgonTextPart, + ThinkingPart as ErgonThinkingPart, + ToolCallPart as ErgonToolCallPart, + ToolReturnPart as ErgonToolReturnPart, + UserPromptPart as ErgonUserPromptPart, +) +from ergon_core.core.persistence.context.event_payloads import ( + AssistantTextPayload, + SystemPromptPayload, + ThinkingPayload, + ToolCallPayload, + ToolResultPayload, + UserMessagePayload, +) +from ergon_core.core.persistence.context.models import RunContextEvent +from pydantic_ai.messages import ( + ModelRequest, + ModelResponse, + SystemPromptPart, + TextPart, + ThinkingPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) +from pydantic_ai.messages import ( + TextPart as PydanticTextPart, +) +from pydantic_ai.messages import ( + ThinkingPart as PydanticThinkingPart, +) +from pydantic_ai.messages import ( + ToolCallPart as PydanticToolCallPart, +) +from pydantic_ai.messages import ( + ToolReturnPart as PydanticToolReturnPart, +) + + +def _make_event(event_type: str, payload, sequence: int) -> RunContextEvent: + return RunContextEvent( + run_id=uuid4(), + task_execution_id=uuid4(), + worker_binding_key="test-worker", + sequence=sequence, + event_type=event_type, + payload=payload.model_dump(mode="json"), + ) + + +def test_text_and_thinking_are_response_parts() -> None: + adapter: TranscriptAdapter[list[ModelRequest | ModelResponse], list[ModelRequest | ModelResponse]] + adapter = PydanticAITranscriptAdapter() + + turns = adapter.build_turns( + [ + ModelRequest(parts=[UserPromptPart(content="hard question")]), + ModelResponse( + parts=[ + ThinkingPart(content="let me reason"), + TextPart(content="answer"), + ] + ), + ] + ) + + assert len(turns) == 1 + turn = turns[0] + assert isinstance(turn, GenerationTurn) + assert any(isinstance(part, ErgonUserPromptPart) for part in turn.messages_in) + assert any(isinstance(part, ErgonThinkingPart) for part in turn.response_parts) + assert any(isinstance(part, ErgonTextPart) for part in turn.response_parts) + + +def test_tool_return_is_attached_to_generating_turn() -> None: + adapter = PydanticAITranscriptAdapter() + + turns = adapter.build_turns( + [ + ModelRequest(parts=[UserPromptPart(content="search")]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name="search", + tool_call_id="call-1", + args={"query": "ergon"}, + ) + ] + ), + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="search", + tool_call_id="call-1", + content={"result": "found"}, + ) + ] + ), + ModelResponse(parts=[TextPart(content="done")]), + ] + ) + + assert len(turns) == 2 + first = turns[0] + assert any(isinstance(part, ErgonToolCallPart) for part in first.response_parts) + assert len(first.tool_results) == 1 + result = first.tool_results[0] + assert isinstance(result, ErgonToolReturnPart) + assert result.tool_call_id == "call-1" + assert result.tool_name == "search" + assert result.content == '{"result": "found"}' + + +def test_assemble_replay_reconstructs_pydantic_ai_messages() -> None: + events = [ + _make_event("system_prompt", SystemPromptPayload(text="sys"), 0), + _make_event("user_message", UserMessagePayload(text="use tool"), 1), + _make_event( + "tool_call", + ToolCallPayload( + tool_call_id="call-1", + tool_name="my_tool", + args={"x": 1}, + turn_id="t1", + ), + 2, + ), + _make_event( + "tool_result", + ToolResultPayload(tool_call_id="call-1", tool_name="my_tool", result="42"), + 3, + ), + _make_event( + "thinking", + ThinkingPayload(text="considering", turn_id="t2"), + 4, + ), + _make_event( + "assistant_text", + AssistantTextPayload(text="The answer is 42.", turn_id="t2"), + 5, + ), + ] + + messages = PydanticAITranscriptAdapter().assemble_replay(events) + + assert len(messages) == 4 + assert isinstance(messages[0], ModelRequest) + assert isinstance(messages[1], ModelResponse) + assert isinstance(messages[2], ModelRequest) + assert isinstance(messages[3], ModelResponse) + assert any(isinstance(part, PydanticToolCallPart) for part in messages[1].parts) + assert any(isinstance(part, PydanticToolReturnPart) for part in messages[2].parts) + assert any(isinstance(part, PydanticThinkingPart) for part in messages[3].parts) + assert any(isinstance(part, PydanticTextPart) for part in messages[3].parts) diff --git a/tests/unit/persistence/test_context_event_repository.py b/tests/unit/persistence/test_context_event_repository.py new file mode 100644 index 00000000..9dc22b13 --- /dev/null +++ b/tests/unit/persistence/test_context_event_repository.py @@ -0,0 +1,107 @@ +from uuid import uuid4 + +import pytest +from ergon_core.api.generation import ( + GenerationTurn, + TextPart, + ThinkingPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) +from ergon_core.core.persistence.definitions.models import ExperimentDefinition +from ergon_core.core.persistence.context.repository import ContextEventRepository +from ergon_core.core.persistence.graph.models import RunGraphNode +from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus +from ergon_core.core.persistence.telemetry.models import RunRecord, RunTaskExecution +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + + +def _session() -> Session: + _ = ExperimentDefinition + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def _execution_fixture(session: Session) -> tuple: + run_id = uuid4() + node = RunGraphNode( + run_id=run_id, + instance_key="instance", + task_slug="task", + description="Task", + status="running", + assigned_worker_slug="worker", + ) + session.add(RunRecord(id=run_id, experiment_definition_id=uuid4(), status=RunStatus.EXECUTING)) + session.add(node) + session.flush() + execution = RunTaskExecution( + run_id=run_id, + node_id=node.id, + status=TaskExecutionStatus.RUNNING, + ) + session.add(execution) + session.commit() + return run_id, execution.id + + +@pytest.mark.asyncio +async def test_persist_turn_records_tool_results_from_tool_results() -> None: + session = _session() + run_id, execution_id = _execution_fixture(session) + + events = await ContextEventRepository().persist_turn( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker", + turn=GenerationTurn( + messages_in=[UserPromptPart(content="search")], + response_parts=[ + ToolCallPart(tool_name="search", tool_call_id="call-1", args={"query": "ergon"}) + ], + tool_results=[ + ToolReturnPart(tool_name="search", tool_call_id="call-1", content="found") + ], + ), + ) + + assert [event.event_type for event in events] == ["user_message", "tool_call", "tool_result"] + tool_result = events[-1].parsed_payload() + assert tool_result.event_type == "tool_result" + assert tool_result.tool_name == "search" + assert tool_result.tool_call_id == "call-1" + assert tool_result.result == "found" + + +@pytest.mark.asyncio +async def test_persist_turn_records_thinking_before_assistant_text() -> None: + session = _session() + run_id, execution_id = _execution_fixture(session) + + events = await ContextEventRepository().persist_turn( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker", + turn=GenerationTurn( + messages_in=[UserPromptPart(content="hard question")], + response_parts=[ + ThinkingPart(content="let me think"), + TextPart(content="answer"), + ], + ), + ) + + assert [event.event_type for event in events] == [ + "user_message", + "thinking", + "assistant_text", + ] diff --git a/tests/unit/state/test_context_assembly.py b/tests/unit/state/test_context_assembly.py index 9345b709..a153a5dd 100644 --- a/tests/unit/state/test_context_assembly.py +++ b/tests/unit/state/test_context_assembly.py @@ -1,12 +1,12 @@ """State tests for context event assembly → PydanticAI message history. -Tests the assemble_pydantic_ai_messages function using RunContextEvent +Tests the PydanticAITranscriptAdapter assemble_replay method using RunContextEvent instances built directly (no DB round-trip needed for pure logic tests). """ from uuid import uuid4 -from ergon_core.core.persistence.context.assembly import assemble_pydantic_ai_messages +from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter from ergon_core.core.persistence.context.event_payloads import ( AssistantTextPayload, SystemPromptPayload, @@ -40,6 +40,10 @@ ) +def assemble_pydantic_ai_messages(events: list[RunContextEvent]): + return PydanticAITranscriptAdapter().assemble_replay(events) + + def _make_event(event_type: str, payload, sequence: int) -> RunContextEvent: run_id = uuid4() exec_id = uuid4() diff --git a/tests/unit/state/test_generation_turn_build.py b/tests/unit/state/test_generation_turn_build.py index f583f932..6268f9a9 100644 --- a/tests/unit/state/test_generation_turn_build.py +++ b/tests/unit/state/test_generation_turn_build.py @@ -1,7 +1,7 @@ # tests/state/test_generation_turn_build.py -"""Tests for the new _build_turns logic in react_worker.""" +"""Tests for building GenerationTurn values from PydanticAI transcripts.""" -from ergon_builtins.workers.baselines.react_worker import _build_turns +from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter from ergon_core.api.generation import ( GenerationTurn, ) @@ -31,6 +31,10 @@ ) +def _build_turns(messages): + return PydanticAITranscriptAdapter().build_turns(messages) + + def _make_messages_text_only(): """One request → one text response (no tools).""" return [ diff --git a/tests/unit/state/test_openrouter_model_resolution.py b/tests/unit/state/test_openrouter_model_resolution.py index 4d2d6561..80525f84 100644 --- a/tests/unit/state/test_openrouter_model_resolution.py +++ b/tests/unit/state/test_openrouter_model_resolution.py @@ -1,4 +1,4 @@ -from ergon_core.core.providers.generation.model_resolution import resolve_model_target +from ergon_builtins.models.resolution import resolve_model_target # Importing the builtins registry registers production model backends. import ergon_builtins.registry # noqa: F401 @@ -11,3 +11,6 @@ def test_openrouter_target_resolves_to_openrouter_provider_model() -> None: assert resolved.model.model_name == "anthropic/claude-sonnet-4.6" assert resolved.model.system == "openrouter" assert resolved.supports_logprobs is False + assert resolved.capture_model_settings == { + "openrouter_reasoning": {"enabled": True, "exclude": False}, + } diff --git a/tests/unit/workers/test_react_worker_contract.py b/tests/unit/workers/test_react_worker_contract.py index 6e9cfdec..4a34ecbc 100644 --- a/tests/unit/workers/test_react_worker_contract.py +++ b/tests/unit/workers/test_react_worker_contract.py @@ -51,3 +51,12 @@ def test_construct_with_minimal_explicit_kwargs() -> None: assert worker.tools == [] assert worker.system_prompt is None assert worker.max_iterations == 1 + + +def test_pydantic_ai_transcript_adapter_lives_outside_worker() -> None: + import ergon_builtins.workers.baselines.react_worker as react_worker + + assert not hasattr(react_worker, "_build_turns") + assert not hasattr(react_worker, "_extract_request_parts") + assert not hasattr(react_worker, "_extract_response_parts") + assert not hasattr(react_worker, "_extract_tool_results") From 2b9788a3f256a60939f49b31cc5d63dbacf7dca3 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:22:57 +0100 Subject: [PATCH 12/66] wip: cli fixes and refactors --- docker-compose.yml | 4 +- .../01-dependency-inversion.md | 418 ++++++++++++ .../02-test-brittleness-and-gaps.md | 441 ++++++++++++ .../03-code-quality.md | 642 ++++++++++++++++++ .../architecture-refactor-audit/README.md | 142 ++++ .../benchmarks/researchrubrics/benchmark.py | 28 +- .../researchrubrics/task_schemas.py | 6 +- .../benchmarks/researchrubrics/vanilla.py | 8 +- .../ergon_builtins/registry_data.py | 2 - .../test_support/smoke_fixtures/benchmarks.py | 2 +- tests/unit/state/test_onboard_profile.py | 5 - .../state/test_research_rubrics_benchmark.py | 23 +- 12 files changed, 1672 insertions(+), 49 deletions(-) create mode 100644 docs/rfcs/active/architecture-refactor-audit/01-dependency-inversion.md create mode 100644 docs/rfcs/active/architecture-refactor-audit/02-test-brittleness-and-gaps.md create mode 100644 docs/rfcs/active/architecture-refactor-audit/03-code-quality.md create mode 100644 docs/rfcs/active/architecture-refactor-audit/README.md diff --git a/docker-compose.yml b/docker-compose.yml index 153cc2be..a2abfdb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,8 +80,8 @@ services: - INNGEST_EVENT_KEY=dev - INNGEST_API_BASE_URL=http://inngest-dev:8288 - ERGON_API_BASE_URL=http://api:9000 - - ENABLE_TEST_HARNESS=${ENABLE_TEST_HARNESS:-1} - - ERGON_STARTUP_PLUGINS=${ERGON_STARTUP_PLUGINS-ergon_core.test_support.smoke_fixtures:register_smoke_fixtures} + - ENABLE_TEST_HARNESS=${ENABLE_TEST_HARNESS:-0} + - ERGON_STARTUP_PLUGINS=${ERGON_STARTUP_PLUGINS:-} - TEST_HARNESS_SECRET=${TEST_HARNESS_SECRET:-local-dev} - ERGON_BLOB_ROOT=/tmp/ergon-blob - OTEL_TRACES_ENABLED=false diff --git a/docs/rfcs/active/architecture-refactor-audit/01-dependency-inversion.md b/docs/rfcs/active/architecture-refactor-audit/01-dependency-inversion.md new file mode 100644 index 00000000..7b0ccfce --- /dev/null +++ b/docs/rfcs/active/architecture-refactor-audit/01-dependency-inversion.md @@ -0,0 +1,418 @@ +--- +status: active +opened: 2026-04-27 +author: GPT-5.5 +architecture_refs: + - docs/architecture/01_public_api.md + - docs/architecture/03_providers.md + - docs/architecture/06_builtins.md +supersedes: [] +superseded_by: null +--- + +# RFC: Dependency Inversion And Package Boundaries + +## Problem + +The declared package graph says `ergon_core` is the reusable runtime and public +API, `ergon_builtins` supplies default implementations, `ergon_cli` adapts user +commands, and `ergon_infra` handles training/provisioning helpers. The source +graph is messier. Core runtime code imports the builtins registry, builtins +tooling imports CLI command modules, and test harness paths pull CLI +composition back into core. + +These dependencies work in the workspace, but they blur ownership. A reader +cannot easily tell which package owns composition, which APIs are stable, or +how to add a new benchmark/worker without coupling to the current default +registry. + +## Current findings + +### Core runtime imports builtins registry + +Runtime paths resolve slugs by importing `ergon_builtins.registry` directly. +This appears in Inngest handlers and services such as worker execution, +benchmark-run startup, evaluator dispatch, sandbox setup, output persistence, +and workflow initialization. The practical result is that core is not only a +runtime contract package; it also knows about the default plugin bundle. + +### Builtins registry reaches into core internals + +`ergon_builtins.registry` implements public `ergon_core.api` contracts, but it +also imports provider internals for model backend registration and sandbox +manager types. Some of this may be unavoidable today, but it should be named as +an extension boundary rather than an incidental import path. + +### Builtins tooling imports CLI command code + +`ergon_builtins.tools.workflow_cli_tool` imports `WorkflowCommandContext`, +`WorkflowCommandOutput`, and `execute_workflow_command` from +`ergon_cli.commands.workflow`. That makes an agent-facing builtin tool depend +on the CLI command layer instead of a shared application/service API. + +### Core test harness imports CLI composition + +`ergon_core.core.api.test_harness` imports `ergon_cli.composition` when the +test harness is enabled. The flag keeps this out of production by default, but +the import direction is still surprising for a core package. + +### CLI composition contains example-specific branches + +`ergon_cli.composition.build_experiment` performs registry lookup and then +branches for smoke workers and `researchrubrics-workflow-cli-react`. Those +branches may encode real composition needs, but they live in the generic CLI +composition path rather than behind benchmark/worker-owned composition hooks. + +## Target shape + +The target dependency direction should be: + +```text +ergon_core.api <- implemented by builtins and custom packages +ergon_core.runtime <- depends on injected registries/services, not builtins +ergon_builtins <- default implementation bundle +ergon_cli <- adapter that wires a registry bundle into core services +ergon_infra <- training/provisioning adapter over public/core services +ergon-dashboard <- frontend over HTTP/event contracts +``` + +Core may define protocols and service interfaces. Builtins may implement them. +CLI and application startup may choose the default builtins registry. Runtime +code should receive a resolver or registry interface rather than importing the +default bundle. + +## Standards proposed + +- Public contracts belong under `ergon_core.api` or a deliberately named core + interface module. +- A package should not import an adapter layer that is higher-level than + itself. In particular, builtins should not import `ergon_cli.commands.*`. +- Runtime services should depend on protocols such as `WorkerResolver`, + `BenchmarkResolver`, `EvaluatorResolver`, `SandboxManagerResolver`, or one + combined `RuntimeRegistry`. +- Example-specific composition should be owned by the benchmark/worker bundle + that requires it, or represented as data on the public API. +- Test-only composition should enter through explicit startup/plugin hooks, not + direct core-to-cli imports. + +## Candidate fixes + +Each candidate below should be treated as a small implementation plan, not an +idea bucket. A follow-up implementation plan may split these into separate PRs, +but each candidate already names the files, steps, tests, and acceptance gate +expected before the work is considered real. + +### DI-1: Add a runtime registry protocol in core + +**Issue fixed:** Core runtime code cannot express "I need a worker/benchmark/evaluator +resolver" without importing the concrete builtins registry, so dependency +direction is encoded as an implementation detail instead of a contract. + +Create a small protocol owned by core that contains the lookup methods runtime +code actually needs: + +- `get_worker(slug)` +- `get_benchmark(slug)` +- `get_evaluator(slug)` +- `get_sandbox_manager(slug)` +- optional install-hint lookup for user-facing errors + +Candidate location: `ergon_core.api.registry` if this becomes public extension +surface, or `ergon_core.core.runtime.registry` if it stays internal. The first +implementation can be an adapter around `ergon_builtins.registry`, preserving +all current slug names and optional-extra behavior. + +Files: + +- Create: `ergon_core/ergon_core/api/registry.py` or + `ergon_core/ergon_core/core/runtime/registry.py`. +- Create: `ergon_builtins/ergon_builtins/runtime_registry.py`. +- Modify: `ergon_builtins/ergon_builtins/registry.py` only if the adapter needs + a stable export. +- Test: `tests/unit/runtime/test_runtime_registry_contract.py`. + +Sketch: + +```python +from typing import Protocol + +class RuntimeRegistry(Protocol): + def get_worker(self, slug: str): ... + def get_benchmark(self, slug: str): ... + def get_evaluator(self, slug: str): ... + def get_sandbox_manager(self, slug: str): ... + def install_hint_for(self, slug: str) -> str | None: ... +``` + +Steps: + +- [ ] Add the protocol and a typed missing-slug error or document that `KeyError` + remains the compatibility behavior. +- [ ] Add a builtins-backed adapter over the existing registry dictionaries. +- [ ] Preserve model backend registration side effects at builtins registry + import time. +- [ ] Add a fake in-memory registry for tests that should not import builtins. +- [ ] Keep existing public imports of `ergon_builtins.registry` working. + +Verification: + +- Unit tests for successful and missing slug lookup. +- Characterization test that CLI defaults still resolve the same worker, + benchmark, evaluator, and sandbox manager classes. +- `python -c "from ergon_builtins.registry import WORKERS, BENCHMARKS"` still + succeeds in the workspace environment. + +Acceptance gate: + +- [ ] Registry contract tests pass for both the fake registry and builtins + adapter. +- [ ] No runtime behavior changes: current benchmark, worker, evaluator, and + sandbox slugs resolve to the same objects. +- [ ] Architecture docs mention where registry protocols live. + +### DI-2: Stop importing `ergon_builtins.registry` from core runtime modules + +**Issue fixed:** `ergon_core` is declared as the reusable runtime package, but +runtime modules currently depend on the default builtins bundle at import time. +That makes builtins a hidden runtime prerequisite and prevents fake/custom +registries from being injected cleanly. + +Replace direct registry imports in core runtime paths with an injected resolver +or application-level registry object. Initial target modules include: + +- `core/runtime/inngest/benchmark_run_start.py` +- `core/runtime/inngest/worker_execute.py` +- `core/runtime/inngest/evaluate_task_run.py` +- `core/runtime/inngest/sandbox_setup.py` +- `core/runtime/inngest/persist_outputs.py` +- `core/runtime/services/workflow_initialization_service.py` +- `core/api/app.py` + +The first pass can use a default registry provider at process startup so +behavior stays identical while import direction improves. + +Files: + +- Modify: `ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py`. +- Modify: `ergon_core/ergon_core/core/runtime/inngest/worker_execute.py`. +- Modify: `ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py`. +- Modify: `ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py`. +- Modify: `ergon_core/ergon_core/core/runtime/inngest/persist_outputs.py`. +- Modify: + `ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py`. +- Modify: `ergon_core/ergon_core/core/api/app.py`. +- Test: `tests/unit/architecture/test_package_boundaries.py`. + +Steps: + +- [ ] Add a process-level registry provider or dependency accessor in core. +- [ ] Configure the builtins-backed registry from CLI/API startup. +- [ ] Convert each runtime module from `from ergon_builtins.registry import ...` + to the registry accessor. +- [ ] Keep error messages for unknown slugs at least as clear as today. +- [ ] Remove any import-time builtins dependency from core runtime modules. + +Verification: + +- Architecture test that `ergon_core.core.runtime` does not import + `ergon_builtins`. +- Existing benchmark/run tests continue to pass without slug changes. +- `rg "ergon_builtins.registry" ergon_core/ergon_core/core/runtime` returns no + matches. + +Acceptance gate: + +- [ ] Direct runtime imports of `ergon_builtins.registry` are gone. +- [ ] Unknown-slug behavior is characterized and preserved or deliberately + improved in a documented way. +- [ ] CLI/API startup still wires the default builtins registry. + +### DI-3: Move workflow command execution out of the CLI command module + +**Issue fixed:** Builtin agent tools reuse workflow behavior by importing +`ergon_cli.commands.workflow`, which makes a non-CLI package depend on CLI +command parsing/rendering code. + +Extract the command parsing/execution core from `ergon_cli.commands.workflow` +into a shared service module that has no CLI rendering dependency. The CLI +command should parse argv and render output; builtin tools should call the same +shared executor directly. + +Candidate owner: `ergon_core.core.runtime.services.workflow_command_service` if +the command surface is runtime-owned, or `ergon_cli.workflow_application` if it +is intentionally an application-layer adapter. The key rule is that +`ergon_builtins` should not import `ergon_cli.commands.*`. + +Verification: + +- Existing `tests/unit/cli/test_workflow_cli.py` still validates CLI behavior. +- New builtin-tool test imports the shared executor without importing the CLI + command module. +- Architecture test blocks `ergon_builtins -> ergon_cli.commands`. + +Files: + +- Create: + `ergon_core/ergon_core/core/runtime/services/workflow_command_service.py` + or a similarly named shared application module. +- Modify: `ergon_cli/ergon_cli/commands/workflow.py`. +- Modify: `ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py`. +- Test: `tests/unit/cli/test_workflow_cli.py`. +- Test: `tests/unit/state/test_workflow_cli_tool.py` or equivalent builtin + tool test. + +Steps: + +- [ ] Identify the current command parser/executor/renderer responsibilities in + `ergon_cli.commands.workflow`. +- [ ] Move parser and executor into the shared module without changing command + strings. +- [ ] Leave stdout/stderr formatting and argparse integration in CLI. +- [ ] Update the builtin workflow tool to call the shared executor. +- [ ] Add an import-boundary test that prevents future builtin imports from + `ergon_cli.commands`. + +Acceptance gate: + +- [ ] CLI workflow tests pass with unchanged expected output. +- [ ] Builtin workflow tool tests pass without importing CLI command modules. +- [ ] `rg "ergon_cli.commands" ergon_builtins/ergon_builtins/tools` returns no + matches, except an explicit migration allowlist if needed. + +### DI-4: Replace special-case CLI experiment branches with composition descriptors + +**Issue fixed:** Generic CLI experiment composition contains hard-coded +knowledge of specific worker families, so every new example with special +bindings risks adding another `if worker_slug == ...` branch. + +Move the smoke-worker and `researchrubrics-workflow-cli-react` branch knowledge +out of generic `build_experiment`. Candidate shape: + +- Workers or benchmarks may expose an optional composition descriptor. +- The descriptor declares extra worker bindings, evaluator bindings, and static + assignment strategy. +- `build_experiment` applies descriptors generically after registry lookup. + +This keeps current behavior while making future examples add data rather than a +new `if worker_slug == ...` branch. + +Verification: + +- Characterization tests for smoke worker composition. +- Characterization tests for research-rubrics workflow composition. +- A test that a synthetic descriptor can add an extra worker binding without + editing `ergon_cli.composition`. + +Files: + +- Modify: `ergon_cli/ergon_cli/composition/__init__.py`. +- Add: a composition descriptor type under `ergon_core.api` or + `ergon_cli.composition`. +- Modify smoke fixture registration under + `ergon_core/ergon_core/test_support/smoke_fixtures/`. +- Modify research-rubrics worker/benchmark registration under + `ergon_builtins/ergon_builtins/workers/research_rubrics/` or + `ergon_builtins/ergon_builtins/registry_data.py`. +- Test: `tests/unit/cli/test_build_experiment_composition.py`. + +Current branches to eliminate from generic composition: + +- `_is_smoke_worker(worker_slug)`. +- `worker_slug == "researchrubrics-workflow-cli-react"`. +- suffix parsing for `-smoke-worker` and `-sadpath-smoke-worker`. +- direct imports of smoke timing criteria from generic CLI composition. + +Sketch: + +```python +class ExperimentCompositionDescriptor(BaseModel): + extra_workers: dict[str, WorkerSpec] + extra_evaluators: dict[str, Evaluator] + static_assignments: dict[str, list[str]] +``` + +Steps: + +- [ ] Add the descriptor type and a no-op default descriptor. +- [ ] Teach `build_experiment` to ask the selected worker/benchmark registry + entry for a descriptor. +- [ ] Move smoke leaf/recursive/failing-leaf bindings into smoke fixture-owned + descriptor code. +- [ ] Move research-rubrics manager/researcher bindings into + research-rubrics-owned descriptor code. +- [ ] Add an architecture test that blocks new hard-coded worker slug branches + in `ergon_cli.composition`. + +Acceptance gate: + +- [ ] No generic composition branch checks a concrete worker slug. +- [ ] Existing smoke and research-rubrics composition behavior is unchanged. +- [ ] A synthetic descriptor test proves new special composition can be added + without editing `build_experiment`. + +### DI-5: Route smoke/test harness composition through startup plugins + +**Issue fixed:** Test harness and smoke-fixture setup rely on direct imports +that blur production startup, CLI composition, and test-support registration. + +Replace direct core-to-CLI composition imports in test-harness paths with the +same registry/composition extension point used by production startup. Smoke +fixtures can still be opt-in, but the opt-in should register providers through +a plugin hook rather than teaching core about CLI composition. + +Verification: + +- Test harness remains disabled by default. +- With `ENABLE_TEST_HARNESS=1`, smoke fixtures still register and run. +- Architecture test documents the only allowed test-support imports. + +Files: + +- Modify: `ergon_core/ergon_core/core/api/test_harness.py`. +- Modify: `ergon_core/ergon_core/core/api/app.py`. +- Modify or use existing startup plugin settings in + `ergon_core/ergon_core/core/settings.py`. +- Test: `tests/unit/architecture/test_smoke_fixture_package_boundary.py`. +- Test: harness tests that currently exercise `ENABLE_TEST_HARNESS`. + +Steps: + +- [ ] Inventory current `ENABLE_TEST_HARNESS` and `ENABLE_SMOKE_FIXTURES` + behavior. +- [ ] Define the plugin hook that can register smoke fixtures or experiment + builders. +- [ ] Move test-harness composition to the plugin path. +- [ ] Preserve disabled-by-default behavior. +- [ ] Add an architecture allowlist for the few remaining test-support imports, + if any. + +Acceptance gate: + +- [ ] Test harness smoke behavior still works under explicit opt-in. +- [ ] Core app startup no longer needs to know smoke fixture implementation + modules by name. +- [ ] Architecture tests fail if new production runtime modules import + `ergon_core.test_support`. + +## Migration / risk + +The risk is not algorithmic behavior; it is import-time behavior. The current +registry performs eager optional-capability imports and model backend +registration. Moving this behind protocols must preserve: + +- Existing CLI defaults and slug names. +- Optional extras behavior and install hints. +- Model backend registration side effects. +- Test harness smoke fixture behavior under explicit flags. + +The first implementation step should be characterization tests around registry +resolution and CLI experiment construction before import paths are changed. + +## Open questions + +- Should the registry protocol live in `ergon_core.api`, `ergon_core.core`, or + a new package such as `ergon_runtime_contracts`? +- Should CLI remain the primary composition root, or should FastAPI startup and + CLI share a new composition module? +- Do existing consumers import `ergon_builtins.registry` directly, and if so do + those imports need compatibility wrappers? diff --git a/docs/rfcs/active/architecture-refactor-audit/02-test-brittleness-and-gaps.md b/docs/rfcs/active/architecture-refactor-audit/02-test-brittleness-and-gaps.md new file mode 100644 index 00000000..6344e914 --- /dev/null +++ b/docs/rfcs/active/architecture-refactor-audit/02-test-brittleness-and-gaps.md @@ -0,0 +1,441 @@ +--- +status: active +opened: 2026-04-27 +author: GPT-5.5 +architecture_refs: + - docs/architecture/07_testing.md + - docs/architecture/02_runtime_lifecycle.md + - docs/architecture/04_persistence.md +supersedes: [] +superseded_by: null +--- + +# RFC: Test Brittleness And Confidence Gaps + +## Problem + +Behavior-preserving refactors need trustworthy tests. Ergon already has useful +unit, integration, e2e, state, and real-LLM tiers, but the test surface has +grown alongside the code. Some tests encode current implementation details, +some test-support concepts leak toward runtime code, and some important +package-boundary expectations are not yet expressed as contracts. + +The goal is to make tests better at preserving behavior while reducing their +ability to freeze accidental architecture. + +## Current findings + +### Test support has explicit gates, but the boundary is fragile + +Smoke fixtures and test harness paths are mostly gated behind environment +flags such as `ENABLE_TEST_HARNESS` and `ENABLE_SMOKE_FIXTURES`. This is +useful, but it means import discipline matters. A small number of direct +imports can turn test-only composition into runtime coupling. + +### Existing architecture tests are valuable but narrow + +There are tests that assert smoke fixtures do not move into old production +paths. That pattern should expand: import-boundary rules should cover core to +builtins, builtins to CLI, and core to CLI exceptions. + +### State tests exercise behavior but may mix concerns + +The `tests/unit/state` tier appears to group workflow/tool/research-rubric +state behavior rather than a dedicated state package. These tests are useful, +but they should make clear whether they are verifying public behavior, database +state transitions, or current helper implementation. + +### Real-LLM and e2e tests are opt-in + +Opt-in real-LLM rollout tests and dashboard/e2e tests are valuable for catching +integration failures, but they are not always part of the fast feedback loop. +The refactor program needs a smaller characterization layer for behavior that +must not change during architecture cleanup. + +### Fixtures can hide missing contracts + +When tests rely on broad fixtures or sentinel identities, they can keep passing +even though production composition boundaries are unclear. Refactors should +prefer explicit fake providers and public-contract setup over reaching into +runtime internals. + +## Target shape + +The test suite should have a clear contract for each tier: + +- **Architecture tests** enforce import direction, package ownership, and + allowed exceptions. +- **Unit tests** verify pure behavior and service logic without requiring the + default builtins registry unless that is the unit under test. +- **State/integration tests** verify persisted runtime transitions through + public service boundaries. +- **E2E tests** verify deployed surfaces and dashboard/API hydration. +- **Real-LLM tests** verify representative model-facing workflows and artifact + health, gated by explicit credentials. + +Each behavior-preserving refactor should start by identifying which tier locks +the behavior being preserved. + +## Standards proposed + +- Add architecture tests for dependency direction and allowed import + exceptions. Exceptions should be named and justified in one place. +- Prefer fake implementations of public protocols over sentinel strings that + runtime code must recognize. +- Keep smoke fixtures and real-LLM harnesses under test-support or tests, with + explicit opt-in registration. +- Avoid tests that assert line-by-line implementation detail unless the detail + is itself a contract. +- For every major refactor, add or identify characterization tests before + moving code. +- Keep slow/e2e/real-LLM tests useful but non-blocking for local refactor + loops; provide smaller contract tests for behavior that must always pass. + +## Candidate fixes + +Each candidate below should include enough detail for an implementation plan to +be written without rediscovering the audit. Tests are themselves part of the +architecture here: they define what future refactors are not allowed to break. + +### TB-1: Add import-boundary architecture tests + +**Issue fixed:** Package-boundary rules are currently mostly social +conventions, so new reverse imports or ad hoc slug branches can land without a +fast test failure. + +Create tests that parse imports and enforce the intended package graph. Start +with warnings/allowlists for current known violations, then tighten the rules +as dependency-inversion fixes land. + +Initial rules: + +- `ergon_core.core.runtime` should not import `ergon_builtins`. +- `ergon_core` should not import `ergon_cli` except explicitly allowed + test-harness paths. +- `ergon_builtins` should not import `ergon_cli.commands`. +- Production runtime modules should not import `ergon_core.test_support` or + `tests.*`. + +Candidate location: `tests/unit/architecture/test_package_boundaries.py`. + +Suggested helper shape: + +```python +def assert_no_imports(package_root: Path, forbidden: str, *, allowlist: set[str]) -> None: + offenders = scan_python_imports(package_root, forbidden) + unexpected = offenders - allowlist + assert unexpected == set() +``` + +Initial allowlist should include only named, reviewed exceptions. Avoid broad +directory-level exceptions unless the whole directory is intentionally an +adapter or test-support surface. + +Steps: + +- [ ] Implement a small AST-based import scanner, not a regex-only test. +- [ ] Add rules for core-to-builtins, core-to-cli, builtins-to-cli, and + production-to-test-support. +- [ ] Encode current known violations as explicit allowlist entries with a + linked candidate fix ID. +- [ ] Add a second test that fails on new concrete worker/benchmark slug + branches in generic composition modules. +- [ ] Document how to update the allowlist when a refactor removes a violation. + +Verification: + +- Test fails with a clear list of violating import edges. +- Current exceptions are named in one allowlist with comments. + +Acceptance gate: + +- [ ] Architecture test passes with only reviewed exceptions. +- [ ] Adding `from ergon_cli.commands...` to a builtin tool fails the test. +- [ ] Adding `worker_slug == "some-example"` to generic composition fails or is + caught by the branch-pattern test. + +### TB-2: Add CLI benchmark-run characterization tests + +**Issue fixed:** The benchmark-run path combines DB setup, experiment +composition, persistence, cohort creation, run creation, event dispatch, and +polling. Refactoring it without characterization tests risks changing behavior +while only moving imports around. + +Before changing composition or registry resolution, lock down the current +observable `ergon benchmark run` setup path without requiring a live Inngest +run: + +- `ensure_db()` is called before persistence. +- `build_experiment()` receives CLI args unchanged. +- `experiment.validate()` runs before `experiment.persist()`. +- cohort resolution uses the explicit cohort or benchmark slug. +- `create_run()` receives the persisted definition. +- `WorkflowStartedEvent` carries the run ID and definition ID. +- polling reads `RunRecord` until a terminal status. + +Candidate location: `tests/unit/cli/test_benchmark_run_flow.py`. + +Suggested cases: + +- `benchmark run` persists before dispatching. +- explicit `--cohort` is used when present. +- default cohort name falls back to benchmark slug. +- timeout returns a timeout handle without pretending the run completed. +- terminal failed/cancelled status exits non-zero. + +Test approach: + +- Monkeypatch `ensure_db`, `build_experiment`, `experiment_cohort_service`, + `create_run`, `inngest_client.send`, and `get_session`. +- Use a fake session whose `get(RunRecord, run.id)` returns a sequence of + statuses. +- Avoid real Postgres, real Inngest, and real builtins imports unless the test + is explicitly about registry wiring. + +Verification: + +- Tests use fakes/mocks at service boundaries, not real Postgres or real + Inngest. +- Refactors of composition/import paths keep this test green. + +Acceptance gate: + +- [ ] A future rewrite of `run_benchmark` can move code around but cannot skip + validate, persist, run creation, event dispatch, or terminal polling. +- [ ] The test names describe user-visible behavior, not private helper calls. + +### TB-3: Add registry protocol contract tests + +**Issue fixed:** Once registry lookup becomes injectable, there is no shared +contract proving that the builtins adapter and test fakes behave the same way. + +Once a registry/resolver protocol exists, test it independently from CLI and +runtime orchestration: + +- known worker/benchmark/evaluator slugs resolve; +- unknown slugs produce a typed error or clear `KeyError`; +- optional install hints remain available; +- model backend registration side effects still happen exactly once. + +Candidate location: `tests/unit/runtime/test_runtime_registry_contract.py` or +`tests/unit/api/test_registry_contract.py`, depending on ownership. + +Verification: + +- Same contract runs against the builtins-backed registry adapter and a small + fake registry used by tests. + +Files: + +- Test: `tests/unit/runtime/test_runtime_registry_contract.py`. +- Fixture/helper: a fake registry implementation near the test or under + `ergon_core.test_support`. +- Optional test: `tests/unit/architecture/test_registry_imports.py`. + +Steps: + +- [ ] Write the contract tests against a fixture parameter named `registry`. +- [ ] Run the same tests against the builtins adapter and fake registry. +- [ ] Assert missing-slug behavior explicitly. +- [ ] Assert install hints do not require importing data-heavy optional extras. +- [ ] Assert model backend registration remains idempotent. + +Acceptance gate: + +- [ ] Runtime services can be tested with fake registries. +- [ ] Builtins adapter passes the same contract as the fake implementation. +- [ ] Contract tests fail if a registry lookup imports CLI code. + +### TB-4: Reclassify `tests/unit/state` by contract type + +**Issue fixed:** The `state` test tier mixes workflow commands, persisted +runtime transitions, worker/tool behavior, benchmark composition, and fixture +behavior under one vague label. + +Add comments, module names, or a README that explains what the "state" tier +means. Then split or rename tests where the current grouping hides intent. + +Suggested categories: + +- workflow command behavior; +- persisted graph/task state transitions; +- worker/tool state interaction; +- research-rubrics benchmark/worker composition; +- fixture-only behavior. + +Verification: + +- A reader can tell why each state test exists without knowing the historical + branch that introduced it. +- No test loses coverage during renaming or movement. + +Files: + +- Add: `tests/unit/state/README.md` or rename/split tests into clearer + directories. +- Review: + `tests/unit/state/test_research_rubrics_workers.py`. +- Review: + `tests/unit/state/test_research_rubrics_benchmark.py`. +- Review workflow/tool state tests in the same directory. + +Steps: + +- [ ] Inventory each state test file and classify it as workflow command, + persisted graph/task transition, worker/tool behavior, benchmark + composition, or fixture behavior. +- [ ] Rename files only when the existing name hides the contract. +- [ ] Move fixture-only behavior under a fixture/test-support category if it is + not testing runtime state. +- [ ] Add README language that "state" is a test tier, not a production domain + package. + +Acceptance gate: + +- [ ] Every file in `tests/unit/state` has an obvious contract category. +- [ ] No test import path changes require production code changes. + +### TB-5: Add fast artifact-health tests for real-LLM assumptions + +**Issue fixed:** Some real-LLM artifact assumptions are only checked in opt-in +credentialed paths, so artifact schema or parser regressions can slip past the +fast local suite. + +The real-LLM artifact-health harness is opt-in, but some assumptions should be +validated without credentials: + +- rollout artifact directories are named and shaped consistently; +- required metadata fields are present; +- failed/incomplete runs produce diagnosable artifacts; +- fixture artifacts exercise the same reader/parser used by real runs. + +Candidate location: extend +`tests/unit/runtime/test_real_llm_rollout_artifact_health.py` or split a helper +contract test nearby. + +Verification: + +- Fast tests run without `ERGON_REAL_LLM`. +- Real-LLM tests remain opt-in but rely on the same artifact validation helper. + +Files: + +- Review/extend: + `tests/unit/runtime/test_real_llm_rollout_artifact_health.py`. +- Review: + `tests/real_llm/artifact_health.py`. +- Review: + `tests/real_llm/rollout.py`. + +Required cases: + +- artifact directory with complete healthy rollout passes; +- missing required metadata fails with actionable error; +- partial failed rollout still produces enough diagnostic fields; +- worker slug extraction handles both snake_case and camelCase shapes; +- fixture artifact parser is the same parser used by real-LLM checks. + +Acceptance gate: + +- [ ] Unit artifact-health tests pass without network credentials. +- [ ] Real-LLM path delegates to the same validation helper. +- [ ] Failure messages name the missing artifact or field. + +### TB-6: Replace sentinel-aware runtime tests with fake provider tests + +**Issue fixed:** Tests that rely on stub sandbox IDs or sentinel parsing +encourage production runtime code to understand test/provider implementation +details. + +Where runtime tests currently require stub or sentinel sandbox identities, +introduce fake provider implementations that satisfy public provider protocols. +The runtime should observe provider behavior, not parse provider-specific +sentinel strings. + +Verification: + +- Tests still cover skipped, failed, cancelled, and cleanup paths. +- Production runtime modules no longer need helpers such as + `is_stub_sandbox_id`. + +Files: + +- Review tests touching sandbox cleanup, cancellation, skipped tasks, and + propagation. +- Add fake provider helpers under `ergon_core/ergon_core/test_support/` only if + they are reusable across test tiers. +- Pair with code cleanup in `core/providers/sandbox/manager.py` only after + characterization tests exist. + +Steps: + +- [ ] Inventory tests that assert or construct stub sandbox IDs. +- [ ] Define fake provider behavior in terms of public provider methods: + create, reconnect, terminate, publish resources. +- [ ] Replace tests that expect sentinel parsing with tests that assert provider + method calls and runtime state transitions. +- [ ] Add an architecture test blocking runtime imports of + `is_stub_sandbox_id`. + +Acceptance gate: + +- [ ] Runtime behavior for skipped/failed/cancelled cleanup is still covered. +- [ ] Runtime code no longer branches on provider-specific sentinel strings. +- [ ] Test fakes live under test support, not production provider modules. + +## Phase gates for the test stream + +### Phase T1 — Boundary tests first + +Scope: + +- `tests/unit/architecture/test_package_boundaries.py`. +- Allowlist current violations with links to `DI-*` / `CQ-*`. + +Acceptance: + +- [ ] Boundary tests pass and fail when a deliberate forbidden import is added + locally. + +### Phase T2 — Characterization before refactor + +Scope: + +- CLI benchmark-run characterization. +- Registry contract tests. +- Artifact-health fast contracts. + +Acceptance: + +- [ ] Refactor candidates have tests that describe the behavior they preserve. +- [ ] No new test requires real Postgres, real Inngest, or real LLM credentials. + +### Phase T3 — Ratchet allowlists down + +Scope: + +- After dependency-inversion and code-quality refactors land, remove resolved + allowlist entries. + +Acceptance: + +- [ ] Import-boundary allowlist shrinks over time. +- [ ] New exceptions require an RFC or explicit architecture-doc note. + +## Migration / risk + +The main risk is over-constraining architecture too early. The first pass +should allow existing known exceptions with comments, then ratchet them down as +refactors land. + +The second risk is test churn without confidence gain. New tests should be +written around observable behavior and import contracts, not around temporary +helper names introduced during the refactor. + +## Open questions + +- Should architecture tests live under `tests/unit/architecture`, or should + there be a dedicated `tests/architecture` tier? +- Which tests should be required before accepting dependency-inversion work? +- Should real-LLM artifact-health checks define a small golden contract that + can run without external model credentials? diff --git a/docs/rfcs/active/architecture-refactor-audit/03-code-quality.md b/docs/rfcs/active/architecture-refactor-audit/03-code-quality.md new file mode 100644 index 00000000..d541ef28 --- /dev/null +++ b/docs/rfcs/active/architecture-refactor-audit/03-code-quality.md @@ -0,0 +1,642 @@ +--- +status: active +opened: 2026-04-27 +author: GPT-5.5 +architecture_refs: + - docs/architecture/README.md + - docs/architecture/01_public_api.md + - docs/architecture/02_runtime_lifecycle.md + - docs/architecture/06_builtins.md +supersedes: [] +superseded_by: null +--- + +# RFC: Code Quality, Duplication, And Complexity + +## Problem + +Fast iteration has left parts of Ergon with high-complexity functions, +branch-heavy example paths, duplicated orchestration logic, and names that no +longer communicate precise ownership. The project already uses Ruff, ty, +slopcop, xenon, and radon-related tooling, but current configuration mostly +documents pre-existing debt rather than defining a refactor target. + +This audit defines the code-quality lens for behavior-preserving cleanup. + +## Current findings + +### Known complexity debt is already listed + +The root `pyproject.toml` has explicit complexity ignores for files such as +experiment persistence, experiment validation, RL rollout/extraction, MiniF2F +loading, file evidence collection, transformer message formatting, and scripts. +Those comments are useful because they identify areas where orchestration has +grown large enough to need ownership review. + +### Generic paths contain example-specific branches + +`ergon_cli.composition.build_experiment` has special branches for smoke workers +and `researchrubrics-workflow-cli-react`. These branches preserve necessary +behavior today, but the pattern does not scale. Generic composition code should +not need to know every benchmark or worker family that requires extra bindings. + +### Tool and workflow code can duplicate service behavior + +CLI command modules, builtin tools, and runtime services all touch workflow +semantics. Without a shared application service boundary, the same concept can +be parsed, validated, or executed in multiple places. + +### Names sometimes encode historical implementation + +Names such as "stub" can mean test double, development default, or lightweight +implementation depending on context. Ambiguous names make it harder to enforce +production/test boundaries and public/private API rules. + +### Deep nesting often reflects missing concepts + +When functions perform lookup, construction, validation, persistence, event +dispatch, and rendering in one flow, nesting and branch count increase. The +answer is not mechanical extraction; it is naming the concepts that already +exist and moving them to the owner that can enforce their invariants. + +## Target shape + +Code quality should be judged against architecture, not only metrics: + +- A module should have one clear owner and one reason to change. +- Public APIs should describe stable concepts, not current storage or CLI + mechanics. +- Composition should be declarative where possible and isolated where it must + branch. +- Runtime orchestration should read as a sequence of named domain operations. +- Tests should cover behavior before complexity-reducing rewrites. + +## Standards proposed + +- Treat new high-complexity ignores as design review triggers, not routine + lint suppressions. +- Prefer small domain objects or command/result types when a function is + passing many loosely related parameters across package boundaries. +- Keep branch-heavy compatibility paths local to adapters or composition + modules, not inside core runtime services. +- Deduplicate only after confirming the duplicated code represents the same + concept. Similar code in different domains may deserve different names. +- Rename "stub", "smoke", and "test" concepts when they are production + defaults or examples rather than test doubles. +- Use architecture docs to record anti-patterns and accepted exceptions so + refactors do not rely on tribal memory. + +## Candidate fixes + +Each candidate below should be concrete enough to become a scoped PR or a +section in an implementation plan. The intent is not generic "clean code"; the +intent is to find where the project encoded missing domain concepts as +duplicated services, private helpers, slug branches, or lint suppressions. + +### CQ-1: Create a complexity ledger from current ignores + +**Issue fixed:** Complexity suppressions are documented inline in +`pyproject.toml`, but there is no owner, smell classification, priority, or +exit criterion for paying the debt down. + +Turn the existing `pyproject.toml` complexity-ignore comments into an explicit +ledger that ranks each offender by risk, ownership, and likely refactor path. + +Initial entries should include: + +- `ExperimentPersistenceService.persist_definition` +- `Experiment.validate` +- RL rollout/extraction helpers +- MiniF2F problem loading +- file evidence collection +- transformer message formatting +- standalone scripts ignored for CLI/script reasons + +Candidate output: a section in this RFC, or a separate +`complexity-ledger.md` in this folder if the list gets long. + +Verification: + +- Every current C901 ignore has an owner, reason, and intended disposition: + keep, split, move, rename, or delete. +- New C901 ignores require adding an entry to the ledger. + +Ledger fields: + +```markdown +| Item | File | Current reason | Domain owner | Smell | Candidate fix | Gate | +|---|---|---|---|---|---|---| +``` + +Smell taxonomy: + +- orchestration doing persistence work; +- validation rules hidden in one large method; +- example-specific branch in generic path; +- private helper cluster that wants a domain object; +- duplicate service responsibility; +- optional dependency/test fallback mixed into production flow. + +Steps: + +- [ ] Convert each current C901 ignore into a ledger row. +- [ ] Run `rg "^def _|^ def _|class .*Service" ergon_core/ergon_core/core/runtime/services` + and add obvious private-helper clusters to the ledger even if not C901. +- [ ] Rank rows by "blocks dependency inversion", "blocks test confidence", + and "local cleanup only". +- [ ] Add a policy that any new C901 ignore must cite a ledger row or RFC. + +Acceptance gate: + +- [ ] The ledger exists and covers every current complexity ignore. +- [ ] The ledger includes at least the large service/private-helper clusters in + `task_management_service.py`, `workflow_service.py`, + `graph_repository.py`, `task_execution_service.py`, and + `experiment_persistence_service.py`. + +### CQ-2: Split experiment composition into generic pipeline plus descriptors + +**Issue fixed:** Generic experiment composition currently knows about concrete +worker families and fixture behavior, which turns every special example into a +potential new branch in shared CLI code. + +Refactor `ergon_cli.composition.build_experiment` so the generic path performs +only these steps: + +1. load registry; +2. construct benchmark/evaluator; +3. ask the selected benchmark/worker for any composition descriptor; +4. build the `Experiment` from descriptors and defaults. + +Current smoke and research-rubrics branches become descriptor providers. This +preserves behavior but removes the pattern where each special worker adds a new +generic CLI branch. + +Verification: + +- Existing smoke and research-rubrics composition tests pass. +- A new fake descriptor test proves a worker can request extra bindings without + changing `build_experiment`. + +Files: + +- Modify: `ergon_cli/ergon_cli/composition/__init__.py`. +- Add descriptor type where selected by `DI-4`. +- Modify smoke fixture registration and research-rubrics registration to + provide descriptors. +- Test: `tests/unit/cli/test_build_experiment_composition.py`. + +Implementation steps: + +- [ ] Write tests that fail on current hard-coded branches being required for + smoke and research-rubrics composition. +- [ ] Add descriptor support with a no-op default. +- [ ] Move smoke branch logic into smoke-owned descriptor provider. +- [ ] Move research-rubrics branch logic into research-rubrics-owned descriptor + provider. +- [ ] Delete `_is_smoke_worker`, `_build_smoke_experiment`, and + `_build_researchrubrics_workflow_experiment` from generic composition + once descriptors cover them. +- [ ] Add an architecture test that blocks new `if worker_slug ==` branches in + generic composition code. + +Acceptance gate: + +- [ ] `ergon_cli.composition` no longer contains concrete worker slug checks. +- [ ] Existing smoke and research-rubrics unit tests pass. +- [ ] New descriptor test demonstrates extension without modifying CLI + composition. + +### CQ-3: Split workflow command execution from CLI rendering + +**Issue fixed:** Workflow parsing, execution, and CLI rendering are coupled +together, causing non-CLI callers to import CLI command modules and making +workflow behavior harder to test independently. + +Separate workflow command concerns into three layers: + +- parser: command string/argv to typed command; +- executor: typed command plus context/session/service to result; +- renderer: result to CLI stdout/stderr text. + +The CLI owns rendering. Builtin agent tools call parser/executor and format +tool-friendly strings. Runtime services own state changes. + +Verification: + +- CLI tests assert the same stdout/stderr behavior. +- Builtin workflow tool tests no longer import `ergon_cli.commands.workflow`. +- Parser/executor tests cover invalid commands, missing context, dry-run paths, + and successful resource/topology operations. + +Files: + +- Add shared parser/executor module selected by `DI-3`. +- Modify: `ergon_cli/ergon_cli/commands/workflow.py`. +- Modify: `ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py`. +- Test: `tests/unit/cli/test_workflow_cli.py`. +- Test: builtin workflow tool test under `tests/unit/state` or a clearer + renamed location. + +Acceptance gate: + +- [ ] CLI rendering remains byte-for-byte compatible where tests already assert + output. +- [ ] Builtin tools no longer import CLI command modules. +- [ ] Shared executor accepts typed context rather than raw argparse namespace. + +### CQ-4: Audit and rename ambiguous "stub" concepts + +**Issue fixed:** The word "stub" is used across test doubles, development +defaults, smoke fixtures, and lightweight implementations, making it unclear +which code is production behavior and which code is test support. + +Classify every "stub" usage into one of four buckets: + +- test double; +- smoke fixture; +- development default; +- lightweight production implementation. + +Then rename where the current name lies about ownership. For example, a +production default should not be named like a test double, while a test fake +should live under test support and use fake/test naming consistently. + +Verification: + +- `rg "stub|smoke|test_harness|test_support"` has an reviewed allowlist for + production packages. +- User-facing CLI defaults do not imply test-only implementations unless they + really are test-only. + +Files: + +- Review: `ergon_core/ergon_core/core/providers/sandbox/manager.py`. +- Review: `ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py`. +- Review: `ergon_core/ergon_core/core/rl/eval_runner.py`. +- Review: `ergon_core/ergon_core/test_support/smoke_fixtures/`. +- Review user-facing CLI defaults in `ergon_cli/ergon_cli/main.py`. + +Steps: + +- [ ] Produce a `stub-smoke-test-naming` section in the complexity ledger or a + small adjacent audit file. +- [ ] Rename test doubles to `Fake*` or `Test*` and move them under + test-support when possible. +- [ ] Rename lightweight production defaults to names that describe their + behavior, not their historical test role. +- [ ] Make production request contracts require explicit worker/evaluator + choices where defaulting to a stub hides behavior. +- [ ] Add tests for any compatibility aliases that must remain. + +Acceptance gate: + +- [ ] Production runtime modules do not branch on "stub" identity. +- [ ] User-facing docs/defaults no longer imply that test doubles are production + defaults. + +### CQ-5: Refactor `persist_definition` behind smaller persistence writers + +**Issue fixed:** Experiment definition persistence is concentrated in one +high-complexity method, so table-writing mechanics and experiment invariants +are hard to review independently. + +`ExperimentPersistenceService.persist_definition` is allowed to be complex +today because it writes a full experiment graph. Keep the transaction boundary, +but split the implementation into named private writer methods or helper +objects: + +- definition row writer; +- worker/evaluator writer; +- instance/task/dependency writer; +- assignment writer; +- task-evaluator link writer. + +The goal is not to change schema or behavior; it is to make persistence +invariants reviewable in smaller units. + +Verification: + +- Existing persistence tests pass. +- Add a focused test for multi-worker assignments if one does not already + cover the branch that motivated CLI special cases. +- Transaction rollback behavior remains unchanged. + +Files: + +- Modify: + `ergon_core/ergon_core/core/runtime/services/experiment_persistence_service.py`. +- Potential new helpers under + `ergon_core/ergon_core/core/persistence/definitions/` if the extracted code + is persistence-model-specific rather than runtime-service-specific. +- Test existing experiment persistence tests, plus add focused tests if missing. + +Implementation steps: + +- [ ] Add characterization tests for single-worker, multi-worker, dependency, + assignment, and evaluator-link persistence. +- [ ] Extract private writer methods without changing transaction boundaries. +- [ ] Name each writer by domain concept, not table name only. +- [ ] Keep `Experiment.persist()` public behavior unchanged. +- [ ] Remove or reduce the C901 ignore only if the extracted shape makes that + honest. + +Acceptance gate: + +- [ ] The service reads as orchestration over named writer steps. +- [ ] Rollback behavior remains a single transaction. +- [ ] Multi-worker assignment behavior is covered by tests. + +### CQ-6: Refactor `Experiment.validate` into rule objects or named validators + +**Issue fixed:** Experiment validation rules are concentrated in one +high-complexity public method, which makes it hard to tell which invariant +failed and hard to add tests for individual rule families. + +Split validation by invariant category while preserving the public +`Experiment.validate()` entrypoint: + +- task uniqueness and dependency validity; +- worker assignment validity; +- evaluator requirement coverage; +- multi-worker/subtask binding validity. + +This makes future public API changes easier to reason about without changing +the caller contract. + +Verification: + +- Existing validation tests pass. +- Each validator has at least one direct test for its failure mode. +- Error messages stay at least as actionable as current messages. + +Files: + +- Modify: `ergon_core/ergon_core/api/experiment.py`. +- Potential create: `ergon_core/ergon_core/api/experiment_validation.py`. +- Test: existing experiment API tests or new + `tests/unit/api/test_experiment_validation.py`. + +Implementation steps: + +- [ ] Snapshot current validation failure messages for representative invalid + experiments. +- [ ] Extract validators for task graph, assignments, evaluator coverage, and + worker bindings. +- [ ] Keep `Experiment.validate()` as the single public entrypoint. +- [ ] Avoid introducing a new public validation framework unless tests show it + pays for itself. + +Acceptance gate: + +- [ ] Public caller behavior is unchanged. +- [ ] Validation rules are testable independently. +- [ ] The original C901 ignore can be removed or justified with a smaller + remaining scope. + +### CQ-7: Establish a "no new branch-if example path" rule + +**Issue fixed:** The codebase has no enforceable guardrail preventing new +example-specific slug checks from being added to generic composition or runtime +paths. + +Add code review guidance and, where possible, tests that reject new generic +composition branches keyed to a specific benchmark or worker slug. The standard +should be: if an example needs special composition, it must declare that need +through a descriptor/hook owned by the example package. + +Verification: + +- Architecture or lint-style test detects new `if worker_slug ==` branches in + generic composition modules, with an allowlist during migration. +- Architecture docs record the accepted extension point. + +Files: + +- Test: `tests/unit/architecture/test_no_ad_hoc_slug_branching.py`. +- Update: `docs/architecture/06_builtins.md` after descriptor/composition + extension point is accepted. + +Rules to enforce: + +- No concrete benchmark/worker/evaluator slug comparisons in generic CLI + composition. +- No suffix parsing for a worker family in generic composition. +- No test-support imports from generic composition unless behind an approved + plugin/harness boundary. +- Slug checks are allowed inside the package that owns the slug family. + +Suggested test inputs: + +- Scan `ergon_cli/ergon_cli/composition`. +- Scan generic runtime services after registry injection is introduced. +- Allowlist current branches only until `CQ-2` lands. + +Acceptance gate: + +- [ ] Adding a new concrete slug branch to generic composition fails tests. +- [ ] Approved extension point is documented. + +### CQ-8: Add module ownership headers only where boundaries are unclear + +**Issue fixed:** Some modules repeatedly attract code from neighboring domains +because their ownership boundary is implicit and only understood by recent +contributors. + +For modules that repeatedly attract misplaced code, add a short top-level +docstring stating what the module owns and what does not belong there. Good +targets are composition, workflow command execution, registry adapters, and +test-support bootstrap modules. + +Verification: + +- Headers are short and enforceable, not narrative. +- Any new ownership statement points to the relevant architecture doc or RFC. + +Candidate modules: + +- `ergon_cli/ergon_cli/composition/__init__.py`. +- Shared workflow command executor introduced by `CQ-3`. +- Registry protocol/adapter modules introduced by `DI-1`. +- Smoke fixture bootstrap modules. +- Runtime services that remain broad after the DDD audit. + +Acceptance gate: + +- [ ] Header says what belongs and what does not belong. +- [ ] Header does not duplicate implementation details. +- [ ] Reviewers can use it to reject misplaced future code. + +### CQ-9: Audit runtime services using DDD-style boundaries + +**Issue fixed:** The runtime services folder contains many service-shaped +modules, but it is not clear which are true domain/application services and +which are duplicated lifecycle fragments or repositories wearing service names. + +The services folder currently contains many service-shaped modules. Some may be +right-sized; others may be procedural clusters that hide duplicate domain +concepts. Audit the folder using domain-driven ownership questions before +moving code: + +- What aggregate or lifecycle does this service own? +- What invariant does it enforce? +- What repositories/providers does it depend on? +- Which other services duplicate the same decision? +- Which private helpers are really domain policies? + +Initial service map to audit: + +```text +ergon_core/ergon_core/core/runtime/services/ + task_management_service.py + task_execution_service.py + workflow_service.py + workflow_initialization_service.py + workflow_finalization_service.py + graph_repository.py + task_cleanup_service.py + task_propagation_service.py + subtask_cancellation_service.py + subtask_blocking_service.py + task_inspection_service.py + experiment_persistence_service.py + evaluator_dispatch_service.py + evaluation_persistence_service.py + rubric_evaluation_service.py + run_service.py + run_read_service.py + cohort_service.py + cohort_stats_service.py + communication_service.py +``` + +Likely duplicate/overlap questions: + +- Do `task_management_service`, `subtask_cancellation_service`, + `subtask_blocking_service`, `task_cleanup_service`, and + `task_propagation_service` encode one task-lifecycle domain or genuinely + separate use cases? +- Does `workflow_service` duplicate graph/resource lookup logic that belongs in + a graph/resource application service? +- Is `graph_repository` both persistence repository and mutation-domain + service? +- Are evaluation dispatch, rubric evaluation, and evaluation persistence cleanly + separated by responsibility? + +Deliverable: + +- Add `04-runtime-service-domain-audit.md` to this RFC folder, or add a + detailed section here if the audit stays short. + +Acceptance gate: + +- [ ] Every service module has a one-sentence responsibility statement. +- [ ] Duplicate responsibilities are listed with candidate merge/split actions. +- [ ] No code moves happen until characterization tests cover the affected + lifecycle. + +### CQ-10: Audit private helpers as design-smell signals + +**Issue fixed:** Large clusters of private helpers can hide missing domain +policies, query objects, DTO mappers, or misplaced responsibilities, but today +they are not audited as architecture signals. + +Private `_` functions are not inherently bad, but clusters of private helpers +often mean the code is compensating for a missing domain object, policy, or +repository. Audit helpers before extracting them mechanically. + +Initial findings to inspect: + +- `task_management_service.py` has validation, invalidation, edge reset, + execution lookup, and dispatch helpers. +- `workflow_service.py` has sandbox manager lookup, task/resource references, + node scope resolution, descendant traversal, producer lookup, and copy + destination helpers. +- `graph_repository.py` has row lookup, sequence allocation, mutation logging, + cycle checks, DTO conversion, and snapshot helpers. +- `task_execution_service.py` has graph-native preparation, definition + preparation, attempt numbering, and status emission. + +Classification: + +- **Keep private helper:** local readability helper with no independent + invariant. +- **Promote to domain policy:** helper encodes a rule that needs tests and a + name. +- **Move to repository/query:** helper is mostly persistence lookup. +- **Move to DTO/mapper:** helper converts persistence rows to transport/domain + objects. +- **Delete after boundary change:** helper exists only because current package + layering is wrong. + +Acceptance gate: + +- [ ] Helper audit identifies at least five helpers to promote/move/delete. +- [ ] Each promoted helper gets a direct test or is covered by an existing + characterization test. +- [ ] No helper is extracted merely to reduce line count without a better name + or owner. + +## Phase gates for the code-quality stream + +### Phase Q1 — Audit before movement + +Scope: + +- Complexity ledger. +- Runtime service domain audit. +- Private-helper audit. +- Ad hoc branch architecture tests with current allowlist. + +Acceptance: + +- [ ] Audits identify concrete files and candidate actions. +- [ ] Tests prevent new ad hoc slug branches. +- [ ] No production behavior changes. + +### Phase Q2 — Composition and workflow cleanup + +Scope: + +- Descriptor-based experiment composition. +- Workflow parser/executor/renderer split. + +Acceptance: + +- [ ] Generic composition has no concrete example slug branches. +- [ ] Builtin tools no longer import CLI command modules. +- [ ] Characterization tests pass. + +### Phase Q3 — Service/domain refactors + +Scope: + +- One lifecycle cluster at a time, chosen from the service domain audit. +- Start with the cluster that blocks dependency inversion or test clarity most. + +Acceptance: + +- [ ] Behavior is locked by characterization tests before moving code. +- [ ] Each extracted domain policy has a named owner and test. +- [ ] Complexity ignores shrink or have updated ledger justification. + +## Migration / risk + +The main risk is aesthetic refactoring that changes behavior or creates more +abstractions without reducing coupling. Refactors should be small enough to +review and should preserve public behavior unless a separate RFC says +otherwise. + +The second risk is over-indexing on cyclomatic complexity. Some orchestration +is inherently sequential and readable. A lower branch count is only a win if +the resulting names clarify invariants and failure modes. + +## Open questions + +- Which complexity metric should become a hard CI gate after the first cleanup + pass: Ruff C901, xenon rank, radon score, or a smaller custom import/size + check? +- Should `ergon_cli.composition` remain one module after descriptors are + introduced, or should it become a package with separate composition owners? +- Which naming changes are worth compatibility wrappers, and which can be + changed directly because they are branch-local implementation details? diff --git a/docs/rfcs/active/architecture-refactor-audit/README.md b/docs/rfcs/active/architecture-refactor-audit/README.md new file mode 100644 index 00000000..6b7eb5c3 --- /dev/null +++ b/docs/rfcs/active/architecture-refactor-audit/README.md @@ -0,0 +1,142 @@ +--- +status: active +opened: 2026-04-27 +author: GPT-5.5 +architecture_refs: + - docs/architecture/README.md + - docs/architecture/01_public_api.md + - docs/architecture/02_runtime_lifecycle.md + - docs/architecture/04_persistence.md + - docs/architecture/06_builtins.md + - docs/architecture/07_testing.md +supersedes: [] +superseded_by: null +--- + +# RFC: Architecture Refactor Audit + +## Problem + +Ergon has moved quickly enough that useful behavior now lives beside accidental +structure: direct package coupling, special-case composition branches, +duplicated setup logic, test-support leakage, and high-complexity orchestration +code. The immediate goal is not to redesign product behavior. It is to make the +existing behavior easier to understand, test, extend, and preserve. + +This RFC folder starts an audit-driven refactor program. It separates the work +into three lenses so each proposal can stay concrete: + +- [`01-dependency-inversion.md`](01-dependency-inversion.md) covers package + boundaries, public API shape, registry resolution, and cross-package imports. +- [`02-test-brittleness-and-gaps.md`](02-test-brittleness-and-gaps.md) covers + brittle tests, fixture boundaries, missing contract tests, and real-LLM/e2e + confidence gaps. +- [`03-code-quality.md`](03-code-quality.md) covers duplication, branch-heavy + example paths, excessive nesting, cyclomatic complexity, naming drift, and + file ownership. + +## Refactor rule + +Behavior stays the same unless a follow-up RFC explicitly changes it. The +program should first extract boundaries, name concepts, move code to better +owners, and add characterization tests around risky flows. Any behavioral +change discovered during cleanup should be split into a separate bug or RFC. + +## Target architecture principles + +1. **Core owns contracts, not default implementations.** `ergon_core` should + expose stable interfaces and runtime services; concrete benchmark, worker, + evaluator, model, and sandbox registrations should be injected through an + explicit composition boundary. +2. **Builtins are plugins, not runtime prerequisites.** `ergon_builtins` should + implement public contracts and provide a default registry bundle without + requiring core runtime imports to know about that bundle. +3. **CLI is an adapter.** `ergon_cli` should parse user input and call shared + application services. Agent tools and core runtime code should not depend on + CLI command modules. +4. **Tests are consumers of public contracts.** Test support may provide + fixtures, fake providers, and smoke registrations, but core code should not + branch on test identities or sentinel values. +5. **Complexity should be paid down near ownership boundaries.** Large + orchestration functions should be split by responsibility only when the + split clarifies invariants or makes behavior easier to test. + +## Proposal + +Adopt this RFC folder as the tracking document for an architecture audit. Each +child document should collect concrete findings, define the target shape, and +list candidate refactors in dependency order. Accepted follow-up RFCs and +implementation plans can then pull from these findings without turning this +folder into a single mega-plan. + +The initial work should prioritize: + +1. Dependency inversion and composition boundaries, because package coupling + makes every later cleanup harder. +2. Test brittleness and missing contract coverage, because behavior-preserving + refactors need confidence. +3. Code quality and complexity cleanup, because it benefits most after the + owning modules and contracts are clearer. + +## Invariants affected + +This audit does not change runtime invariants by itself. It may produce +follow-up RFCs that update: + +- `docs/architecture/01_public_api.md` if public API ownership changes. +- `docs/architecture/02_runtime_lifecycle.md` if runtime composition or task + orchestration boundaries change. +- `docs/architecture/06_builtins.md` if registry/plugin semantics change. +- `docs/architecture/07_testing.md` if test tier responsibilities change. + +## Migration + +No code migration is proposed in this folder directly. Migration guidance lives +inside each child audit document and should be converted into implementation +plans only after the target architecture is accepted. + +Before implementation, each refactor should have: + +- A characterization test or existing test reference for the behavior being + preserved. +- A clear package-boundary statement: what module owns the new abstraction and + which packages may import it. +- A rollback path if the refactor uncovers behavior that differs from the docs. + +## Alternatives considered + +### One giant architecture RFC + +This would be easy to create, but it would encourage broad, vague findings and +make acceptance difficult. Dependency inversion, tests, and code quality have +different audiences and different risk profiles. + +### Three unrelated top-level RFCs + +This would make each stream independently acceptable, but it would hide the +shared refactor goal. The folder keeps the audit cohesive while preserving +focused documents. + +### Immediate code cleanup without an audit + +This risks preserving the current accidental architecture under new names. +Because the goal is behavior-preserving refactor, the first deliverable should +be shared understanding and standards. + +## Open questions + +- Which package boundary should own registry resolution: core, a new + composition package, or the CLI/application layer? +- How much backward compatibility is required for current import paths inside + the repo? +- Should complexity thresholds become CI-enforced once the first cleanup pass + lands, or should they remain advisory until the major offenders are reduced? + +## On acceptance + +When this RFC folder is accepted: + +- Move the folder or accepted child docs under `docs/rfcs/accepted/`. +- Link the first implementation plan in `docs/superpowers/plans/`. +- Update affected architecture docs with any new import-boundary or testing + invariants. diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py index 6d343710..974e2b0c 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py @@ -8,7 +8,6 @@ from typing import Any, ClassVar from datasets import load_dataset -from huggingface_hub import HfApi from ergon_core.api.benchmark import Benchmark from ergon_core.api.benchmark_deps import BenchmarkDeps @@ -23,13 +22,14 @@ class ResearchRubricsBenchmark(Benchmark): """Benchmark backed by the ResearchRubrics HuggingFace dataset. - ``build_instances`` loads samples from the (ablated) HuggingFace dataset - and returns one task per sample. Each task's ``task_payload`` carries the - full ``ResearchRubricsTaskPayload`` so the rubric and worker can - reconstruct criteria and prompts. + ``build_instances`` loads official ScaleAI ResearchRubrics samples and + returns one task per sample. Each task's ``task_payload`` carries the full + ``ResearchRubricsTaskPayload`` so the rubric and worker can reconstruct + criteria and prompts. """ type_slug: ClassVar[str] = "researchrubrics" + dataset_name: ClassVar[str] = "ScaleAI/researchrubrics" task_payload_model: ClassVar[type[ResearchRubricsTaskPayload]] = ResearchRubricsTaskPayload onboarding_deps: ClassVar[BenchmarkDeps] = BenchmarkDeps( extras=("ergon-builtins[data]",), @@ -41,7 +41,6 @@ class ResearchRubricsBenchmark(Benchmark): def __init__( self, *, - dataset_name: str | None = None, limit: int | None = None, name: str | None = None, description: str | None = None, @@ -52,7 +51,6 @@ def __init__( description=description or "ResearchRubrics deep-research benchmark", metadata=metadata, ) - self.dataset_name = dataset_name self.limit = limit # ------------------------------------------------------------------ @@ -65,7 +63,7 @@ def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[ResearchRubrics BenchmarkTask[ResearchRubricsTaskPayload]( task_slug=payload.sample_id, instance_key="default", - description=payload.ablated_prompt, + description=payload.prompt, evaluator_binding_keys=("default",), task_payload=payload, ) @@ -82,19 +80,11 @@ def _load_rows(self) -> list[ResearchRubricsTaskPayload]: Requires ``datasets`` and ``huggingface_hub`` to be installed. """ - dataset_name = self.dataset_name # reason: avoids circular import at module level from ergon_core.core.settings import settings token = settings.hf_api_key - if dataset_name is None: - if token is None: - raise RuntimeError("HF_API_KEY must be set when dataset_name is not provided") - api = HfApi(token=token) - user_info = api.whoami() - dataset_name = f"{user_info['name']}/researchrubrics-ablated" - - ds = load_dataset(dataset_name, token=token) + ds = load_dataset(self.dataset_name, token=token) train_ds = ds["train"] if self.limit: @@ -110,7 +100,7 @@ def _payload_from_row( return ResearchRubricsTaskPayload( sample_id=row["sample_id"], domain=str(row.get("domain", "")), - ablated_prompt=row["ablated_prompt"], + prompt=row["prompt"], rubrics=[ RubricCriterion( criterion=r["criterion"], @@ -119,6 +109,4 @@ def _payload_from_row( ) for r in row["rubrics"] ], - removed_elements=row.get("removed_elements"), - ablation_type=row.get("ablation_type"), ) diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/task_schemas.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/task_schemas.py index 07a47607..4fd98ca4 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/task_schemas.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/task_schemas.py @@ -32,9 +32,5 @@ class ResearchRubricsTaskPayload(BaseModel): "Business Planning & Research, Technical Documentation, etc." ), ) - ablated_prompt: str = Field(description="Ablated prompt (what worker sees)") + prompt: str = Field(description="Official ResearchRubrics task prompt") rubrics: list[RubricCriterion] = Field(description="List of evaluation criteria") - removed_elements: list[str] | None = Field( - default=None, description="Elements removed during ablation" - ) - ablation_type: str | None = Field(default=None, description="Type of ablation applied") diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/vanilla.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/vanilla.py index 10b420a1..a7d3f1d6 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/vanilla.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/vanilla.py @@ -1,7 +1,6 @@ -"""ResearchRubrics vanilla benchmark (ScaleAI's official dataset). +"""ResearchRubrics vanilla benchmark alias. -Used for the paper's headline number. Inherits all logic from the base -``ResearchRubricsBenchmark`` and overrides only the dataset name. +Kept as a registry-compatible alias for the official ScaleAI dataset. """ from collections.abc import Mapping @@ -13,7 +12,7 @@ class ResearchRubricsVanillaBenchmark(ResearchRubricsBenchmark): - """ScaleAI's official ResearchRubrics dataset (un-ablated). + """Compatibility alias for ScaleAI's official ResearchRubrics dataset. Used for the paper's headline number. """ @@ -27,7 +26,6 @@ def __init__( metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] ) -> None: super().__init__( - dataset_name="ScaleAI/researchrubrics", limit=limit, name="researchrubrics-vanilla", description=( diff --git a/ergon_builtins/ergon_builtins/registry_data.py b/ergon_builtins/ergon_builtins/registry_data.py index 55cf6b07..215eaf69 100644 --- a/ergon_builtins/ergon_builtins/registry_data.py +++ b/ergon_builtins/ergon_builtins/registry_data.py @@ -27,7 +27,6 @@ BENCHMARKS: dict[str, type[Benchmark]] = { "gdpeval": GDPEvalBenchmark, "researchrubrics": ResearchRubricsBenchmark, - "researchrubrics-ablated": ResearchRubricsBenchmark, "researchrubrics-vanilla": ResearchRubricsVanillaBenchmark, } @@ -48,6 +47,5 @@ SANDBOX_MANAGERS: dict[str, type[BaseSandboxManager]] = { "researchrubrics": ResearchRubricsSandboxManager, - "researchrubrics-ablated": ResearchRubricsSandboxManager, "researchrubrics-vanilla": ResearchRubricsSandboxManager, } diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py b/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py index adc18874..35bd1eba 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py @@ -54,7 +54,7 @@ class ResearchRubricsSmokeBenchmark(_SingleTaskSmokeBenchmark): task_payload: ClassVar[JsonObject] = { "sample_id": "smoke-001", "domain": "smoke", - "ablated_prompt": "Write a short smoke-test research report.", + "prompt": "Write a short smoke-test research report.", "rubrics": [ { "criterion": "Report contains the expected smoke-test marker.", diff --git a/tests/unit/state/test_onboard_profile.py b/tests/unit/state/test_onboard_profile.py index 374e1773..bc82047b 100644 --- a/tests/unit/state/test_onboard_profile.py +++ b/tests/unit/state/test_onboard_profile.py @@ -152,10 +152,6 @@ def test_researchrubrics_smoke_has_no_e2b(self) -> None: assert "E2B_API_KEY" not in p.required_keys() assert p.required_extras() == [] - def test_researchrubrics_ablated_needs_data_extra(self) -> None: - p = OnboardProfile(benchmarks=["researchrubrics-ablated"]) - assert "ergon-builtins[data]" in p.required_extras() - def test_researchrubrics_vanilla_needs_data_extra(self) -> None: p = OnboardProfile(benchmarks=["researchrubrics-vanilla"]) assert "ergon-builtins[data]" in p.required_extras() @@ -176,7 +172,6 @@ def test_wizard_sees_all_registered_slugs(self) -> None: "swebench-verified", "gdpeval", "researchrubrics", - "researchrubrics-ablated", "researchrubrics-vanilla", } assert expected <= set(BENCHMARKS.keys()) diff --git a/tests/unit/state/test_research_rubrics_benchmark.py b/tests/unit/state/test_research_rubrics_benchmark.py index c656d63a..9ae8a534 100644 --- a/tests/unit/state/test_research_rubrics_benchmark.py +++ b/tests/unit/state/test_research_rubrics_benchmark.py @@ -14,10 +14,10 @@ class TestResearchRubricsBenchmarkRegistration: """Verify benchmark slugs resolve correctly in the registry.""" - def test_researchrubrics_ablated_registered(self): - """researchrubrics-ablated resolves to ResearchRubricsBenchmark.""" - assert "researchrubrics-ablated" in BENCHMARKS - assert BENCHMARKS["researchrubrics-ablated"] is ResearchRubricsBenchmark + def test_researchrubrics_registered(self): + """researchrubrics resolves to the official ScaleAI dataset benchmark.""" + assert BENCHMARKS["researchrubrics"] is ResearchRubricsBenchmark + assert set(BENCHMARKS) == {"gdpeval", "researchrubrics", "researchrubrics-vanilla"} assert issubclass(ResearchRubricsBenchmark, Benchmark) def test_researchrubrics_vanilla_registered(self): @@ -50,7 +50,7 @@ def __getitem__(self, idx): return { "sample_id": "sample", "domain": "quality", - "ablated_prompt": "Write a report.", + "prompt": "Write a report.", "rubrics": [ {"criterion": "Includes citations.", "axis": "quality", "weight": 2.0}, ], @@ -105,7 +105,7 @@ def __getitem__(self, idx): return { "sample_id": "sample", "domain": "quality", - "ablated_prompt": "Write a report.", + "prompt": "Write a report.", "rubrics": [ {"criterion": "Includes citations.", "axis": "quality", "weight": 2.0}, ], @@ -116,17 +116,22 @@ def __getitem__(self, idx): lambda *args, **kwargs: {"train": FakeTrainDataset()}, ) - rows = ResearchRubricsBenchmark(dataset_name="fake/researchrubrics")._load_rows() + rows = ResearchRubricsBenchmark()._load_rows() assert rows == [ ResearchRubricsTaskPayload( sample_id="sample", domain="quality", - ablated_prompt="Write a report.", + prompt="Write a report.", rubrics=[{"criterion": "Includes citations.", "axis": "quality", "weight": 2.0}], ) ] + def test_default_dataset_is_official_scaleai_dataset(self): + benchmark = ResearchRubricsBenchmark(limit=1) + + assert benchmark.dataset_name == "ScaleAI/researchrubrics" + class TestResearchRubricsRubric: """Verify task-payload-driven rubric construction.""" @@ -142,7 +147,7 @@ def test_can_construct_without_prebound_criteria(self): { "sample_id": "sample", "domain": "quality", - "ablated_prompt": "Write a report.", + "prompt": "Write a report.", "rubrics": [ {"criterion": "Includes citations.", "axis": "quality", "weight": 2.0}, {"criterion": "No unsupported claims.", "axis": "quality", "weight": -1.0}, From e361806f752946e68a0f2cd0c1c062e664d13ca8 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:21:01 +0100 Subject: [PATCH 13/66] docs: plan core schema deduplication Made-with: Cursor --- .../2026-04-28-core-schema-deduplication.md | 1178 +++++++++++++++++ 1 file changed, 1178 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-28-core-schema-deduplication.md diff --git a/docs/superpowers/plans/2026-04-28-core-schema-deduplication.md b/docs/superpowers/plans/2026-04-28-core-schema-deduplication.md new file mode 100644 index 00000000..db086f5d --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-core-schema-deduplication.md @@ -0,0 +1,1178 @@ +# Core Schema Deduplication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make core workflow statuses, evaluation statuses, graph mutation payloads, event causes, and projection schemas have one clear source of truth per domain. + +**Architecture:** Keep persisted table schemas in `core/persistence/*`, graph lifecycle conventions in `core/persistence/graph/status_conventions.py`, typed graph mutation payloads in `core/runtime/services/graph_dto.py`, evaluation summary status in `core/persistence/telemetry/evaluation_summary.py`, and transport-specific projections in `core/api/schemas.py` and `core/dashboard/event_contracts.py`. REST and dashboard layers may project canonical DTOs, but must not redefine domain meaning. + +**Tech Stack:** Python 3.13, Pydantic v2, SQLModel, pytest, ty-compatible type aliases, existing Ergon core runtime/persistence packages. + +--- + +## Source Of Truth Decisions + +| Concept | Source of truth | Consumers should import from | Cleanup rule | +|---|---|---|---| +| Run row lifecycle | `ergon_core.core.persistence.shared.enums.RunStatus` | `core.persistence.shared.enums` | Only use for `RunRecord.status` and run-level orchestration. | +| Task execution row lifecycle | `ergon_core.core.persistence.shared.enums.TaskExecutionStatus` | `core.persistence.shared.enums` | Only use for `RunTaskExecution.status`; do not use it as the graph-node status type. | +| Graph node lifecycle | `ergon_core.core.persistence.graph.status_conventions.NodeStatus` and constants | `core.persistence.graph.status_conventions` | Use for `RunGraphNode.status`, propagation, subtask inspection, dashboard task-node status, and graph DTO status annotations. | +| Graph edge lifecycle | `ergon_core.core.persistence.graph.status_conventions.EdgeStatus` and constants | `core.persistence.graph.status_conventions` | Use for `RunGraphEdge.status` and edge mutation/status changes. | +| Graph target and mutation names | `GraphTargetType`, `MutationType` in `core/persistence/graph/models.py` | `core.persistence.graph.models` | Keep because these are persisted mutation-log contract names. | +| Graph mutation payload body | `GraphMutationValue` union in `core/runtime/services/graph_dto.py` | `core.runtime.services.graph_dto` | REST and dashboard events import this union; no separate payload definitions. | +| Evaluation criterion status | `EvalCriterionStatus` in `core/persistence/telemetry/evaluation_summary.py` | `core.persistence.telemetry.evaluation_summary` | REST evaluation DTOs import this alias. | +| Cancel cause | `CancelCause` in `core/runtime/events/task_events.py` | `core.runtime.events.task_events` | Services that accept cancel causes import the shared alias or narrower named aliases from the same module. | +| Context event payloads | `ContextEventType`, `ContextEventPayload` in `core/persistence/context/event_payloads.py` | `core.persistence.context.event_payloads` | REST/dashboard context event snapshots should use the canonical type where practical. | +| Generation transcript parts | `core/generation.py` | `core.generation` | Keep separate from context event payloads; add adapter tests for the mapping instead of merging naming schemes. | + +--- + +## DTO Collapse Targets + +The cleanup should collapse duplicate DTOs when two classes carry the same domain payload with only superficial transport differences. Keep separate models only when the shape is genuinely different at the boundary. + +| Current duplication | Collapse target | Keep separate? | Why | +|---|---|---|---| +| `GraphMutationDto`, `RunGraphMutationDto`, `DashboardGraphMutationEvent` repeat mutation identity/body fields | Add canonical `GraphMutationRecordDto` in `core/runtime/services/graph_dto.py`; REST returns it, dashboard event embeds it or is a thin envelope around it | Keep dashboard event envelope only | Mutation body and metadata are one concept; REST/dashboard differ only by transport envelope and timestamp naming. | +| `RunContextEventDto` and `DashboardContextEventEvent` repeat context-event fields, but REST is untyped | Add canonical `ContextEventDto` near `core/persistence/context/event_payloads.py` or `core/runtime/services/context_dto.py`; both REST and dashboard use `ContextEventType` + `ContextEventPayload` | Keep event envelope name only | Same persisted event snapshot should not have typed dashboard payload and untyped REST payload. | +| `WorkflowTaskRef` mostly duplicates a subset of `GraphNodeDto` | Prefer `GraphNodeDto` directly where the full node snapshot is acceptable; otherwise create one canonical `GraphTaskRef` in `graph_dto.py` and use it across workflow DTOs | Maybe | CLI/tool responses may intentionally omit fields, but the current separate class adds another status/name surface. | +| `RunTaskDto` and `TaskTreeNode` both represent UI task nodes but one is map-oriented and one is recursive | Extract a shared `TaskNodeSnapshot` payload if frontend compatibility allows; keep `RunSnapshotDto.tasks: dict[str, ...]` and `DashboardWorkflowStartedEvent.task_tree` as containers | Yes, containers differ | Map vs tree is a real transport difference; the task-node payload fields should not drift. | +| `TestGraphNodeDto` and `TestGraphMutationDto` are Playwright-only projections | Leave separate but derive from canonical DTO conversion helpers where possible | Yes | Test harness is intentionally narrow/additive-only, but should not define new domain semantics. | + +Rule: collapse the payload, not necessarily the envelope. For example, `DashboardGraphMutationEvent` can remain an event contract, but it should carry the same canonical mutation record/payload as REST and repository code. + +--- + +## File Structure + +**Modify:** +- `ergon_core/ergon_core/core/persistence/graph/status_conventions.py` — canonical graph status aliases, terminal/settled helpers, and small predicates. +- `ergon_core/ergon_core/core/runtime/execution/propagation.py` — use graph status constants consistently and align failure docs/results with `BLOCKED` behavior. +- `ergon_core/ergon_core/core/runtime/services/task_propagation_service.py` — remove stale cancellation wording and stop exposing unused invalidated targets from normal propagation if tests confirm it is dead. +- `ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py` — remove dead `TaskCancelledEvent` emission from propagation if `invalidated_targets` is removed. +- `ergon_core/ergon_core/core/runtime/services/orchestration_dto.py` — simplify `PropagationResult` around actual ready/block/terminal outcomes. +- `ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py` — use `NodeStatus` directly instead of duplicating or aliasing `SubtaskStatus`. +- `ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py` — keep `EvalCriterionStatus` canonical. +- `ergon_core/ergon_core/core/api/schemas.py` — import `EvalCriterionStatus`, remove duplicate mutation/context payload bodies, and keep REST projection thin. +- `ergon_core/ergon_core/core/runtime/services/graph_dto.py` — make `GraphMutationValue` the only typed mutation payload body and make edge mutation IDs consistent with graph DTO ID types. +- `ergon_core/ergon_core/core/dashboard/event_contracts.py` — keep event envelopes but reuse canonical graph mutation/context event DTO payloads. +- `ergon_core/ergon_core/core/runtime/events/task_events.py` — keep `CancelCause` canonical and add subset aliases if services need narrower inputs. +- `ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py` — import shared cancel-cause aliases instead of duplicating string literals. +- `ergon_core/ergon_core/core/runtime/services/subtask_blocking_service.py` — share graph skip predicates from `status_conventions.py`. + +**Add or modify tests:** +- `tests/unit/architecture/test_core_schema_sources.py` — architecture guard for duplicate literals and forbidden imports. +- `tests/unit/runtime/test_propagation_contracts.py` or existing propagation tests — assert failure propagation blocks downstream nodes and does not emit cancellation targets. +- `tests/unit/runtime/test_graph_mutation_contracts.py` or existing graph repository tests — assert REST/dashboard mutation payloads accept the same `GraphMutationValue` body. +- Existing focused tests: `tests/unit/runtime/test_workflow_service.py`, `tests/unit/runtime/test_dynamic_task_evaluation_mapping.py`, `tests/unit/dashboard/test_event_contract_types.py`, `tests/unit/architecture/test_model_field_descriptions.py`. + +--- + +### Task 1: Guard Canonical Status Ownership + +**Files:** +- Modify: `tests/unit/architecture/test_core_schema_sources.py` +- Modify: `ergon_core/ergon_core/core/persistence/graph/status_conventions.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py` + +- [ ] **Step 1: Write architecture tests that fail on duplicated graph status literals** + +Create `tests/unit/architecture/test_core_schema_sources.py` with this first test: + +```python +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] + + +def test_graph_status_literals_are_defined_only_in_status_conventions() -> None: + offenders: list[str] = [] + duplicate_snippets = ( + 'Literal["pending", "ready", "running", "completed", "failed", "cancelled", "blocked"]', + 'Literal["pending", "ready", "running", "completed", "failed", "blocked", "cancelled"]', + 'Literal["pending", "satisfied", "invalidated"]', + ) + allowed = { + ROOT / "ergon_core/ergon_core/core/persistence/graph/status_conventions.py", + } + + for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): + if path in allowed: + continue + text = path.read_text() + for snippet in duplicate_snippets: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} duplicates {snippet}") + + assert offenders == [] +``` + +- [ ] **Step 2: Run the new test and verify it fails** + +Run: `uv run pytest tests/unit/architecture/test_core_schema_sources.py::test_graph_status_literals_are_defined_only_in_status_conventions -v` + +Expected: FAIL because `task_inspection_dto.py` duplicates the node status `Literal`. + +- [ ] **Step 3: Add canonical helpers to `status_conventions.py`** + +Update `ergon_core/ergon_core/core/persistence/graph/status_conventions.py`: + +```python +NodeStatus = Literal["pending", "ready", "running", "completed", "failed", "cancelled", "blocked"] + +NON_AUTONOMOUS_STATUSES = TERMINAL_STATUSES | frozenset({BLOCKED}) + + +def is_terminal_node_status(status: str) -> bool: + return status in TERMINAL_STATUSES + + +def is_blockable_node_status(status: str) -> bool: + return status != RUNNING and status not in TERMINAL_STATUSES +``` + +Keep `EdgeStatus` in the same file. Do not move graph statuses to `shared/enums.py`; graph status intentionally remains string-backed because `RunGraphNode.status` is free-form at the database layer. + +- [ ] **Step 4: Replace `SubtaskStatus` with `NodeStatus` at the field boundary** + +Update `ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py`: + +```python +from ergon_core.core.persistence.graph.status_conventions import NodeStatus +from ergon_core.core.persistence.shared.types import NodeId +from pydantic import BaseModel +``` + +Change the model field from: + +```python +status: SubtaskStatus +``` + +to: + +```python +status: NodeStatus +``` + +Delete the `SubtaskStatus` name entirely. If any downstream call site imports `SubtaskStatus`, update that call site to import `NodeStatus` from `status_conventions.py` instead. The goal is one concept name for graph-node lifecycle state. + +- [ ] **Step 5: Run focused tests** + +Run: `uv run pytest tests/unit/architecture/test_core_schema_sources.py tests/unit/state/test_subtask_lifecycle_toolkit.py tests/unit/runtime/test_workflow_service.py -v` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tests/unit/architecture/test_core_schema_sources.py ergon_core/ergon_core/core/persistence/graph/status_conventions.py ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py +git commit -m "Consolidate graph status conventions" +``` + +--- + +### Task 2: Separate Graph Status From Task Execution Status In Propagation + +**Files:** +- Modify: `tests/unit/runtime/test_propagation_contracts.py` +- Modify: `ergon_core/ergon_core/core/runtime/execution/propagation.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/task_propagation_service.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/task_execution_service.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py` + +- [ ] **Step 1: Write tests for graph-node status constants at every graph write boundary** + +Add `tests/unit/runtime/test_propagation_contracts.py`: + +```python +from ergon_core.core.persistence.graph import status_conventions as graph_status +from ergon_core.core.runtime.execution import propagation +from ergon_core.core.runtime.services import task_execution_service, task_propagation_service +from ergon_core.core.runtime.services import workflow_initialization_service + + +def _source(module: object) -> str: + loader = getattr(module, "__loader__") + source = loader.get_source(module.__name__) + assert source is not None + return source + + +def test_graph_writers_do_not_use_task_execution_status_for_node_status() -> None: + modules = [ + propagation, + task_execution_service, + task_propagation_service, + workflow_initialization_service, + ] + forbidden_snippets = ( + "new_status=TaskExecutionStatus.", + "initial_node_status=TaskExecutionStatus.", + ) + + offenders = [ + f"{module.__name__}: {snippet}" + for module in modules + for snippet in forbidden_snippets + if snippet in _source(module) + ] + + assert offenders == [] + assert graph_status.READY == "ready" +``` + +This is an architecture test. It is intentionally string-based because the cleanup goal is import-boundary clarity. + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `uv run pytest tests/unit/runtime/test_propagation_contracts.py::test_graph_writers_do_not_use_task_execution_status_for_node_status -v` + +Expected: FAIL because `propagation.py`, `task_propagation_service.py`, `task_execution_service.py`, and `workflow_initialization_service.py` currently use `TaskExecutionStatus` values while writing graph-node status. + +- [ ] **Step 3: Update propagation imports** + +In `ergon_core/ergon_core/core/runtime/execution/propagation.py`, replace direct status imports with a module alias: + +```python +from ergon_core.core.persistence.graph import status_conventions as graph_status +``` + +Remove `TaskExecutionStatus` from `propagation.py` if it becomes unused. This module operates on `RunGraphNode` / `RunGraphEdge`, so all graph-node writes and graph-node comparisons must use `graph_status.*`. + +- [ ] **Step 4: Update graph node writes** + +Change graph-node status writes: + +```python +new_status=graph_status.PENDING +new_status=graph_status.RUNNING +new_status=graph_status.FAILED +new_status=graph_status.BLOCKED +``` + +Change comparisons: + +```python +is_success = terminal_status == graph_status.COMPLETED +if target_node.status == graph_status.RUNNING: +if target_node.status in graph_status.TERMINAL_STATUSES: +is_pending = status == graph_status.PENDING +is_reactivatable_cancelled = status == graph_status.CANCELLED and is_managed_subtask +if all(n is not None and n.status == graph_status.COMPLETED for n in source_nodes): +``` + +- [ ] **Step 5: Update service calls into propagation** + +In `task_propagation_service.py`, call `on_task_completed_or_failed` with graph status constants: + +```python +from ergon_core.core.persistence.graph import status_conventions as graph_status +``` + +Use: + +```python +new_status=graph_status.COMPLETED +terminal_status=graph_status.COMPLETED +new_status=graph_status.FAILED +terminal_status=graph_status.FAILED +new_status=graph_status.PENDING +``` + +- [ ] **Step 6: Update task execution graph writes without changing execution-row writes** + +In `task_execution_service.py`, keep `TaskExecutionStatus` for `RunTaskExecution.status` assignments: + +```python +execution = RunTaskExecution( + ... + status=TaskExecutionStatus.RUNNING, +) +execution.status = TaskExecutionStatus.COMPLETED +execution.status = TaskExecutionStatus.FAILED +``` + +But change graph-node updates and dashboard node-status emissions to graph status constants: + +```python +from ergon_core.core.persistence.graph import status_conventions as graph_status + +await self._graph_repo.update_node_status( + ..., + new_status=graph_status.RUNNING, + ... +) + +await _emit_task_status( + ..., + new_status=graph_status.RUNNING, + ... +) +``` + +For finalization events that are explicitly reporting task-node lifecycle state, use: + +```python +new_status=graph_status.COMPLETED +old_status=graph_status.RUNNING +new_status=graph_status.FAILED +``` + +The rule is: `TaskExecutionStatus` belongs to `RunTaskExecution.status`; `graph_status` belongs to `RunGraphNode.status` and dashboard task-node status payloads. + +- [ ] **Step 7: Update workflow initialization graph seeding** + +In `workflow_initialization_service.py`, keep `RunStatus.EXECUTING` for `RunRecord.status`, but change graph initialization inputs: + +```python +from ergon_core.core.persistence.graph import status_conventions as graph_status + +graph_repo.initialize_from_definition( + ..., + initial_node_status=graph_status.PENDING, + initial_edge_status=graph_status.EDGE_PENDING, + ... +) +``` + +- [ ] **Step 8: Run focused tests** + +Run: `uv run pytest tests/unit/runtime/test_propagation_contracts.py tests/unit/runtime/test_workflow_service.py tests/unit/runtime/test_dynamic_task_evaluation_mapping.py tests/unit/runtime/test_failure_error_json.py tests/unit/runtime/test_worker_execute_factory_call.py tests/unit/runtime/test_smoke_topology_drift.py -v` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add tests/unit/runtime/test_propagation_contracts.py ergon_core/ergon_core/core/runtime/execution/propagation.py ergon_core/ergon_core/core/runtime/services/task_propagation_service.py ergon_core/ergon_core/core/runtime/services/task_execution_service.py ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py +git commit -m "Use graph status conventions in propagation" +``` + +--- + +### Task 3: Align Failure Propagation Contract With BLOCKED Behavior + +**Files:** +- Modify: `tests/unit/runtime/test_propagation_contracts.py` +- Modify: `ergon_core/ergon_core/core/runtime/execution/propagation.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/orchestration_dto.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/task_propagation_service.py` +- Modify: `ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py` + +- [ ] **Step 1: Add a contract test for no cancellation targets from propagation** + +Extend `tests/unit/runtime/test_propagation_contracts.py`: + +```python +from ergon_core.core.runtime.services.orchestration_dto import PropagationResult + + +def test_propagation_result_does_not_expose_invalidated_targets() -> None: + assert "invalidated_targets" not in PropagationResult.model_fields +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `uv run pytest tests/unit/runtime/test_propagation_contracts.py::test_propagation_result_does_not_expose_invalidated_targets -v` + +Expected: FAIL because `PropagationResult` currently has `invalidated_targets`. + +- [ ] **Step 3: Simplify `PropagationResult`** + +In `orchestration_dto.py`, remove the field: + +```python +invalidated_targets: list[UUID] = Field(default_factory=list) +``` + +Keep: + +```python +ready_tasks: list[TaskDescriptor] = Field(default_factory=list) +workflow_terminal_state: WorkflowTerminalState = WorkflowTerminalState.NONE +``` + +- [ ] **Step 4: Update `on_task_completed_or_failed` return type and docs** + +In `propagation.py`, change: + +```python +) -> tuple[list[UUID], list[UUID]]: +``` + +to: + +```python +) -> list[UUID]: +``` + +Update the docstring to say: + +```python +"""Handle a node reaching COMPLETED, FAILED, or CANCELLED. + +Returns newly ready node IDs. + +- COMPLETED: outgoing edges become SATISFIED; targets with all dependencies + satisfied transition to PENDING for scheduling. +- FAILED / CANCELLED: outgoing edges become INVALIDATED; reachable successors + transition to BLOCKED unless they are RUNNING or terminal. +""" +``` + +Remove the local `invalidated: list[UUID] = []` and return only `newly_ready`. + +- [ ] **Step 5: Update `TaskPropagationService`** + +Change: + +```python +newly_ready_node_ids, invalidated_node_ids = await on_task_completed_or_failed(...) +``` + +to: + +```python +newly_ready_node_ids = await on_task_completed_or_failed(...) +``` + +Remove `invalidated_targets=invalidated_node_ids` from returned `PropagationResult`. + +For failure propagation, change: + +```python +_ready, invalidated_node_ids = await on_task_completed_or_failed(...) +``` + +to: + +```python +await on_task_completed_or_failed(...) +``` + +Update docstrings to say failure blocks downstream graph nodes, not cancels them. + +- [ ] **Step 6: Remove dead cancellation emission from `propagate_execution.py`** + +Remove the import: + +```python +TaskCancelledEvent, +``` + +Remove the loop: + +```python +for inv_node_id in propagation.invalidated_targets: + events.append(...) +``` + +Keep `TaskCancelledEvent` in `task_events.py`; it is still used by manager/operator cancellation flows. + +- [ ] **Step 7: Run focused tests** + +Run: `uv run pytest tests/unit/runtime/test_propagation_contracts.py tests/unit/runtime/test_smoke_topology_drift.py tests/unit/runtime/test_dynamic_task_evaluation_mapping.py tests/unit/runtime/test_failed_task_sandbox_cleanup.py -v` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add tests/unit/runtime/test_propagation_contracts.py ergon_core/ergon_core/core/runtime/execution/propagation.py ergon_core/ergon_core/core/runtime/services/orchestration_dto.py ergon_core/ergon_core/core/runtime/services/task_propagation_service.py ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py +git commit -m "Align propagation contract with blocked successors" +``` + +--- + +### Task 4: Consolidate Evaluation Criterion Status + +**Files:** +- Modify: `tests/unit/architecture/test_core_schema_sources.py` +- Modify: `ergon_core/ergon_core/core/api/schemas.py` +- Confirm: `ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py` + +- [ ] **Step 1: Add architecture test for duplicate evaluation status literals** + +Add to `tests/unit/architecture/test_core_schema_sources.py`: + +```python +def test_eval_criterion_status_literal_is_defined_only_in_evaluation_summary() -> None: + offenders: list[str] = [] + snippet = 'EvalCriterionStatus = Literal["passed", "failed", "errored", "skipped"]' + allowed = { + ROOT / "ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py", + } + + for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): + if path in allowed: + continue + if snippet in path.read_text(): + offenders.append(str(path.relative_to(ROOT))) + + assert offenders == [] +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `uv run pytest tests/unit/architecture/test_core_schema_sources.py::test_eval_criterion_status_literal_is_defined_only_in_evaluation_summary -v` + +Expected: FAIL because `core/api/schemas.py` currently defines the same alias. + +- [ ] **Step 3: Import canonical alias in REST schemas** + +In `core/api/schemas.py`, replace: + +```python +from typing import Any, Literal +EvalCriterionStatus = Literal["passed", "failed", "errored", "skipped"] +``` + +with: + +```python +from typing import Any +from ergon_core.core.persistence.telemetry.evaluation_summary import EvalCriterionStatus +``` + +- [ ] **Step 4: Run focused tests** + +Run: `uv run pytest tests/unit/architecture/test_core_schema_sources.py tests/unit/runtime/test_evaluation_summary_contracts.py tests/unit/runtime/test_dynamic_task_evaluation_mapping.py -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/unit/architecture/test_core_schema_sources.py ergon_core/ergon_core/core/api/schemas.py +git commit -m "Use canonical evaluation criterion status" +``` + +--- + +### Task 5: Collapse Graph Mutation DTOs Onto One Canonical Record + +**Files:** +- Modify: `tests/unit/runtime/test_graph_mutation_contracts.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/graph_dto.py` +- Modify: `ergon_core/ergon_core/core/api/schemas.py` +- Modify: `ergon_core/ergon_core/core/dashboard/event_contracts.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/graph_repository.py` +- Modify: `ergon_core/ergon_core/core/dashboard/emitter.py` + +- [ ] **Step 1: Write mutation contract tests** + +Create `tests/unit/runtime/test_graph_mutation_contracts.py`: + +```python +from uuid import uuid4 + +from ergon_core.core.dashboard.event_contracts import DashboardGraphMutationEvent +from ergon_core.core.runtime.services.graph_dto import ( + EdgeAddedMutation, + GraphMutationRecordDto, + GraphMutationValue, +) +from pydantic import TypeAdapter + + +def test_rest_and_dashboard_mutations_share_graph_mutation_record_payloads() -> None: + run_id = uuid4() + mutation_id = uuid4() + edge_id = uuid4() + source_id = uuid4() + target_id = uuid4() + + payload = EdgeAddedMutation( + source_node_id=source_id, + target_node_id=target_id, + status="pending", + ) + + TypeAdapter(GraphMutationValue).validate_python(payload.model_dump(mode="json")) + + record = GraphMutationRecordDto( + id=mutation_id, + run_id=run_id, + sequence=1, + mutation_type="edge.added", + target_type="edge", + target_id=edge_id, + actor="test", + old_value=None, + new_value=payload, + reason=None, + created_at="2026-04-28T00:00:00Z", + ) + dashboard = DashboardGraphMutationEvent( + mutation=record, + ) + + assert dashboard.mutation == record + assert record.new_value == payload +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `uv run pytest tests/unit/runtime/test_graph_mutation_contracts.py::test_rest_and_dashboard_mutations_share_graph_mutation_record_payloads -v` + +Expected: FAIL because `GraphMutationRecordDto` does not exist yet and `DashboardGraphMutationEvent` currently duplicates mutation fields instead of wrapping one canonical record. + +- [ ] **Step 3: Make edge mutation IDs consistent with graph DTO IDs** + +In `graph_dto.py`, change: + +```python +source_node_id: str +target_node_id: str +``` + +to: + +```python +source_node_id: NodeId +target_node_id: NodeId +``` + +for both `EdgeAddedMutation` and `EdgeRemovedMutation`. + +If JSON serialization needs strings, keep conversion at the API/dashboard serialization boundary with `model_dump(mode="json")`; do not weaken the canonical payload type. + +- [ ] **Step 4: Add canonical mutation record DTO** + +In `graph_dto.py`, add: + +```python +from datetime import datetime + + +class GraphMutationRecordDto(BaseModel): + """Append-only graph mutation record with a typed mutation payload.""" + + model_config = {"frozen": True} + + id: UUID + run_id: RunId + sequence: int + mutation_type: MutationType + target_type: GraphTargetType + target_id: UUID + actor: str + old_value: GraphMutationValue | None + new_value: GraphMutationValue + reason: str | None + created_at: datetime +``` + +- [ ] **Step 5: Replace REST mutation DTO with canonical record** + +In `core/api/schemas.py`, remove `RunGraphMutationDto` and import: + +```python +from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto +``` + +Update `core/api/runs.py` and `run_read_service.py` so `/runs/{run_id}/mutations` returns `list[GraphMutationRecordDto]`. Keep JSON stringification at FastAPI/Pydantic serialization, not in a second REST DTO. + +- [ ] **Step 6: Collapse dashboard event to a thin envelope** + +In `event_contracts.py`, replace duplicated mutation fields with: + +```python +from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto + + +class DashboardGraphMutationEvent(InngestEventContract): + name: ClassVar[str] = "dashboard/graph.mutation" + + mutation: GraphMutationRecordDto +``` + +If frontend contract compatibility requires top-level fields for one release, stop and ask before adding a compatibility shim; the requested direction is to reduce duplicate DTOs. + +- [ ] **Step 7: Update repository/emitter conversion code** + +Search for mutation construction: + +```bash +rg "EdgeAddedMutation|EdgeRemovedMutation|GraphMutationValue|DashboardGraphMutationEvent|RunGraphMutationDto|GraphMutationRecordDto" ergon_core/ergon_core/core tests -n +``` + +Update `_to_mutation_dto` / mutation read paths to produce `GraphMutationRecordDto`. Update `dashboard/emitter.py` to construct `DashboardGraphMutationEvent(mutation=record)` instead of copying fields. Update call sites to pass UUID/`NodeId` values into `EdgeAddedMutation` / `EdgeRemovedMutation`. Use `model_dump(mode="json")` only when writing JSON columns or sending wire payloads. + +- [ ] **Step 8: Run focused mutation/dashboard tests** + +Run: `uv run pytest tests/unit/runtime/test_graph_mutation_contracts.py tests/unit/dashboard/test_event_contract_types.py tests/unit/architecture/test_model_field_descriptions.py -v` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add tests/unit/runtime/test_graph_mutation_contracts.py ergon_core/ergon_core/core/runtime/services/graph_dto.py ergon_core/ergon_core/core/api/schemas.py ergon_core/ergon_core/core/dashboard/event_contracts.py ergon_core/ergon_core/core/runtime/services/graph_repository.py ergon_core/ergon_core/core/dashboard/emitter.py +git commit -m "Unify graph mutation payload contracts" +``` + +--- + +### Task 6: Collapse Task Node Projections Where Shapes Are Accidental + +**Files:** +- Modify: `tests/unit/architecture/test_core_schema_sources.py` +- Modify: `ergon_core/ergon_core/core/api/schemas.py` +- Modify: `ergon_core/ergon_core/core/api/runs.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/graph_dto.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/workflow_dto.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/workflow_service.py` +- Modify: `ergon_core/ergon_core/core/dashboard/event_contracts.py` +- Modify: `ergon_core/ergon_core/core/runtime/inngest/start_workflow.py` + +- [ ] **Step 1: Add tests for task-node DTO collapse** + +Add to `tests/unit/architecture/test_core_schema_sources.py`: + +```python +def test_run_task_dto_does_not_label_worker_slug_as_name() -> None: + path = ROOT / "ergon_core/ergon_core/core/api/schemas.py" + text = path.read_text() + assert "assigned_worker_name" not in text + assert "assigned_worker_slug" in text + + +def test_workflow_task_ref_does_not_duplicate_graph_task_ref() -> None: + path = ROOT / "ergon_core/ergon_core/core/runtime/services/workflow_dto.py" + assert "class WorkflowTaskRef" not in path.read_text() +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `uv run pytest tests/unit/architecture/test_core_schema_sources.py::test_run_task_dto_does_not_label_worker_slug_as_name tests/unit/architecture/test_core_schema_sources.py::test_workflow_task_ref_does_not_duplicate_graph_task_ref -v` + +Expected: FAIL because `RunTaskDto` currently has `assigned_worker_name` and `workflow_dto.py` currently defines `WorkflowTaskRef`. + +- [ ] **Step 3: Rename REST task field to match its actual value** + +In `core/api/schemas.py`, change: + +```python +assigned_worker_name: str | None = None +``` + +to: + +```python +assigned_worker_slug: str | None = None +``` + +In `core/api/runs.py`, change the `_build_task_map` assignment from `assigned_worker_name=...` to `assigned_worker_slug=...`. + +- [ ] **Step 4: Introduce one canonical lightweight graph task ref** + +In `graph_dto.py`, add: + +```python +class GraphTaskRef(BaseModel): + """Lightweight task-node reference for workflow/tool projections.""" + + model_config = {"frozen": True} + + node_id: NodeId + task_slug: str + status: NodeStatus + level: int + parent_node_id: NodeId | None = None + assigned_worker_slug: str | None = None +``` + +Import `NodeStatus` from `status_conventions.py`. + +- [ ] **Step 5: Replace `WorkflowTaskRef` with `GraphTaskRef`** + +In `workflow_dto.py`, remove `WorkflowTaskRef` and import: + +```python +from ergon_core.core.runtime.services.graph_dto import GraphTaskRef +``` + +Update fields: + +```python +source: GraphTaskRef +target: GraphTaskRef +task: GraphTaskRef +task: GraphTaskRef | None = None +``` + +In `workflow_service.py`, update `_task_ref` to return `GraphTaskRef`. + +- [ ] **Step 6: Keep map-vs-tree containers, but share task-node semantics** + +Add or update comments near `RunTaskDto`: + +```python +class RunTaskDto(CamelModel): + """REST projection of RunGraphNode for run detail pages. + + This is not the canonical graph schema; graph semantics live in + runtime/services/graph_dto.py and persistence/graph/status_conventions.py. + """ +``` + +Keep `RunSnapshotDto.tasks: dict[str, RunTaskDto]` and `DashboardWorkflowStartedEvent.task_tree: TaskTreeNode` because map and tree containers are genuinely different. But align their field names and statuses with `GraphTaskRef`: `assigned_worker_slug` means slug, `status` is `NodeStatus`, and dependency/child fields are container-specific additions rather than new task-node semantics. + +- [ ] **Step 7: Run focused API/dashboard/workflow tests** + +Run: `uv run pytest tests/unit/architecture/test_core_schema_sources.py tests/unit/cli/test_workflow_cli.py tests/unit/dashboard/test_event_contract_types.py tests/unit/state/test_workflow_cli_tool.py -v` + +Expected: PASS. If frontend TypeScript expects `assignedWorkerName`, update that in a separate frontend-compatible task rather than sneaking it into this backend cleanup. + +- [ ] **Step 8: Commit** + +```bash +git add tests/unit/architecture/test_core_schema_sources.py ergon_core/ergon_core/core/api/schemas.py ergon_core/ergon_core/core/api/runs.py ergon_core/ergon_core/core/runtime/services/graph_dto.py ergon_core/ergon_core/core/runtime/services/workflow_dto.py ergon_core/ergon_core/core/runtime/services/workflow_service.py ergon_core/ergon_core/core/dashboard/event_contracts.py ergon_core/ergon_core/core/runtime/inngest/start_workflow.py +git commit -m "Collapse duplicate task node projections" +``` + +--- + +### Task 7: Reuse CancelCause Instead Of Local Literal Subsets + +**Files:** +- Modify: `tests/unit/architecture/test_core_schema_sources.py` +- Modify: `ergon_core/ergon_core/core/runtime/events/task_events.py` +- Modify: `ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py` +- Modify: any caller that accepts the same literal subset. + +- [ ] **Step 1: Add architecture test for local cancel-cause literals** + +Add to `tests/unit/architecture/test_core_schema_sources.py`: + +```python +def test_cancel_cause_literals_live_in_task_events() -> None: + offenders: list[str] = [] + snippets = ( + 'Literal["parent_terminal", "dep_invalidated"]', + 'Literal["dep_invalidated", "parent_terminal"]', + ) + allowed = { + ROOT / "ergon_core/ergon_core/core/runtime/events/task_events.py", + } + + for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): + if path in allowed: + continue + text = path.read_text() + for snippet in snippets: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} duplicates cancel cause subset") + + assert offenders == [] +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `uv run pytest tests/unit/architecture/test_core_schema_sources.py::test_cancel_cause_literals_live_in_task_events -v` + +Expected: FAIL if `subtask_cancellation_service.py` still defines a local subset literal. + +- [ ] **Step 3: Add named subset aliases in `task_events.py`** + +In `task_events.py`, below `CancelCause`, add: + +```python +PropagationCancelCause = Literal["parent_terminal", "dep_invalidated"] +``` + +This keeps narrower service typing but centralizes the strings. + +- [ ] **Step 4: Import the subset alias in services** + +In `subtask_cancellation_service.py`, replace the local `Literal[...]` import/annotation with: + +```python +from ergon_core.core.runtime.events.task_events import PropagationCancelCause +``` + +Use: + +```python +cause: PropagationCancelCause +``` + +- [ ] **Step 5: Run focused cancellation tests** + +Run: `uv run pytest tests/unit/runtime/test_failed_task_sandbox_cleanup.py tests/unit/runtime/test_dynamic_task_evaluation_mapping.py tests/unit/state/test_subtask_lifecycle_toolkit.py tests/unit/architecture/test_core_schema_sources.py -v` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tests/unit/architecture/test_core_schema_sources.py ergon_core/ergon_core/core/runtime/events/task_events.py ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py +git commit -m "Centralize task cancellation causes" +``` + +--- + +### Task 8: Collapse Context Event Snapshot DTOs Onto Typed Payloads + +**Files:** +- Modify: `tests/unit/runtime/test_context_event_contracts.py` +- Modify: `ergon_core/ergon_core/core/api/schemas.py` +- Modify: `ergon_core/ergon_core/core/api/runs.py` +- Modify: `ergon_core/ergon_core/core/dashboard/event_contracts.py` +- Modify: `ergon_core/ergon_core/core/dashboard/emitter.py` + +- [ ] **Step 1: Write a context event DTO sharing test** + +Create `tests/unit/runtime/test_context_event_contracts.py`: + +```python +from uuid import uuid4 + +from ergon_core.core.api.schemas import RunContextEventDto +from ergon_core.core.dashboard.event_contracts import DashboardContextEventEvent +from ergon_core.core.persistence.context.event_payloads import AssistantTextPayload + + +def test_rest_and_dashboard_context_events_share_typed_payload_shape() -> None: + payload = AssistantTextPayload(text="hello") + common = { + "id": uuid4(), + "run_id": uuid4(), + "task_execution_id": uuid4(), + "task_node_id": uuid4(), + "worker_binding_key": "worker", + "sequence": 1, + "event_type": "assistant_text", + "payload": payload, + "created_at": "2026-04-28T00:00:00Z", + "started_at": None, + "completed_at": None, + } + + rest = RunContextEventDto.model_validate(common) + dashboard = DashboardContextEventEvent.model_validate(common) + + assert rest.payload == dashboard.payload + assert rest.event_type == dashboard.event_type +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `uv run pytest tests/unit/runtime/test_context_event_contracts.py::test_rest_and_dashboard_context_events_share_typed_payload_shape -v` + +Expected: FAIL because `RunContextEventDto` currently uses `event_type: str` and `payload: dict[str, Any]`, while dashboard uses `ContextEventType` and `ContextEventPayload`. + +- [ ] **Step 3: Type REST context event DTO with canonical event payloads** + +In `core/api/schemas.py`, import: + +```python +from ergon_core.core.persistence.context.event_payloads import ( + ContextEventPayload, + ContextEventType, +) +``` + +Update: + +```python +event_type: ContextEventType +payload: ContextEventPayload +``` + +- [ ] **Step 4: Update REST context event construction** + +In `core/api/runs.py`, when building `RunContextEventDto`, validate payload with the canonical discriminated payload type. If rows already store dict payloads, use the same validation path as dashboard emitter uses rather than passing raw dicts through REST. + +- [ ] **Step 5: Decide whether to fully collapse class names** + +If `RunContextEventDto` and `DashboardContextEventEvent` now have the same fields except event `name`, move the common fields into a shared model: + +```python +class ContextEventDto(CamelModel or BaseModel): + ... +``` + +Use that model directly in REST and embed it in the dashboard event envelope. If camelCase REST output makes a shared class awkward, keep the two envelope classes but require both to use `ContextEventType` and `ContextEventPayload`. + +- [ ] **Step 6: Run focused tests** + +Run: `uv run pytest tests/unit/runtime/test_context_event_contracts.py tests/unit/dashboard/test_event_contract_types.py tests/unit/architecture/test_model_field_descriptions.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add tests/unit/runtime/test_context_event_contracts.py ergon_core/ergon_core/core/api/schemas.py ergon_core/ergon_core/core/api/runs.py ergon_core/ergon_core/core/dashboard/event_contracts.py ergon_core/ergon_core/core/dashboard/emitter.py +git commit -m "Share typed context event payload schemas" +``` + +--- + +### Task 9: Add Mapping Guard Between Generation Parts And Context Events + +**Files:** +- Modify: `tests/unit/builtins/common/test_transcript_adapters.py` +- Modify: `ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py` only if the test reveals unmapped kinds. + +- [ ] **Step 1: Add explicit adapter coverage for vocabulary mapping** + +In `tests/unit/builtins/common/test_transcript_adapters.py`, add a test that documents the intended split between `core.generation` kebab-case `part_kind` and context event snake-case `event_type`: + +```python +from ergon_core.core.generation import TextPart, ThinkingPart, ToolCallPart, ToolReturnPart +from ergon_core.core.persistence.context.event_payloads import ContextEventType + + +def test_generation_part_kinds_have_context_event_counterparts() -> None: + assert TextPart(content="x").part_kind == "text" + assert ThinkingPart(content="x").part_kind == "thinking" + assert ToolCallPart(tool_name="t", tool_call_id="1", args={}).part_kind == "tool-call" + assert ToolReturnPart(tool_call_id="1", tool_name="t", content="ok").part_kind == "tool-return" + + assert "assistant_text" in ContextEventType.__args__ + assert "thinking" in ContextEventType.__args__ + assert "tool_call" in ContextEventType.__args__ + assert "tool_result" in ContextEventType.__args__ +``` + +- [ ] **Step 2: Run the test** + +Run: `uv run pytest tests/unit/builtins/common/test_transcript_adapters.py::test_generation_part_kinds_have_context_event_counterparts -v` + +Expected: PASS if the current split is intentional and covered; FAIL if any expected context event value has drifted. + +- [ ] **Step 3: Fix adapter mapping only if the test fails** + +If the test fails because context event values changed, update `ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py` to map the actual canonical context event types. Do not merge generation parts and context events into one model family. + +- [ ] **Step 4: Run focused adapter tests** + +Run: `uv run pytest tests/unit/builtins/common/test_transcript_adapters.py tests/unit/persistence/test_context_event_repository.py -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/unit/builtins/common/test_transcript_adapters.py ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py +git commit -m "Guard generation to context event mapping" +``` + +--- + +### Task 10: Final Architecture Sweep + +**Files:** +- Modify: `tests/unit/architecture/test_core_schema_sources.py` +- Modify: `docs/superpowers/plans/2026-04-28-core-schema-deduplication.md` only if implementation reveals a necessary correction. + +- [ ] **Step 1: Add a broad forbidden-duplication guard** + +Add to `tests/unit/architecture/test_core_schema_sources.py`: + +```python +def test_core_schema_source_imports_are_directional() -> None: + forbidden_pairs = { + "ergon_core.core.api.schemas": ( + "EvalCriterionStatus = Literal", + "GraphMutationValue =", + ), + "ergon_core.core.dashboard.event_contracts": ( + "GraphMutationValue =", + "CancelCause = Literal", + ), + } + + offenders: list[str] = [] + for module_path, snippets in forbidden_pairs.items(): + path = ROOT / (module_path.replace(".", "/") + ".py") + text = path.read_text() + for snippet in snippets: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} contains local source {snippet!r}") + + assert offenders == [] +``` + +- [ ] **Step 2: Run the full architecture test set** + +Run: `uv run pytest tests/unit/architecture -v` + +Expected: PASS. + +- [ ] **Step 3: Run focused runtime/schema tests** + +Run: + +```bash +uv run pytest \ + tests/unit/runtime/test_workflow_service.py \ + tests/unit/runtime/test_dynamic_task_evaluation_mapping.py \ + tests/unit/runtime/test_evaluation_summary_contracts.py \ + tests/unit/dashboard/test_event_contract_types.py \ + tests/unit/builtins/common/test_transcript_adapters.py \ + tests/unit/architecture/test_model_field_descriptions.py \ + -v +``` + +Expected: PASS. + +- [ ] **Step 4: Search for remaining duplicate literals** + +Run: + +```bash +rg 'Literal\["pending", "ready", "running", "completed", "failed", "cancelled", "blocked"\]|EvalCriterionStatus = Literal|invalidated_targets|assigned_worker_name|Literal\["parent_terminal", "dep_invalidated"\]' ergon_core tests +``` + +Expected output may include only: + +```text +ergon_core/ergon_core/core/persistence/graph/status_conventions.py +ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py +tests/unit/architecture/test_core_schema_sources.py +``` + +If other production files appear, either import the canonical alias or explain in a code comment why the duplicate-looking concept is distinct. + +- [ ] **Step 5: Run lints for touched files** + +Use Cursor lints for: + +```text +ergon_core/ergon_core/core/persistence/graph/status_conventions.py +ergon_core/ergon_core/core/runtime/execution/propagation.py +ergon_core/ergon_core/core/runtime/services +ergon_core/ergon_core/core/api/schemas.py +ergon_core/ergon_core/core/dashboard/event_contracts.py +tests/unit/architecture/test_core_schema_sources.py +``` + +Expected: no new diagnostics in touched files. + +- [ ] **Step 6: Commit final guard changes** + +```bash +git add tests/unit/architecture/test_core_schema_sources.py +git commit -m "Guard core schema source ownership" +``` + +--- + +## Execution Notes + +- Do not collapse legitimate transport envelopes into one giant schema. Do collapse duplicated payload bodies: `WorkflowTaskRef` should disappear in favor of `GraphTaskRef`; REST/dashboard task containers can remain map/tree envelopes only if their field semantics align with the canonical graph task ref. +- Do remove duplicate domain definitions. If two modules need the same literal values, one imports from the source-of-truth module. +- Keep table models free-form where the database intentionally allows extension, but make runtime conventions explicit through aliases and constants. +- Keep REST/dashboard serialization at the boundary. Canonical Python DTOs can use UUID/NewType fields; wire models can stringify with `model_dump(mode="json")`. +- Avoid compatibility facades. If a module owns a concept, import it directly from that module. + +## Self-Review + +- Spec coverage: high-priority graph status duplication, evaluation status duplication, stale propagation contract, graph mutation DTO collapse, task-node DTO collapse, context-event DTO typing, cancel-cause duplication, and generation/context event vocabulary mapping are each covered by a task. +- Placeholder scan: no task contains unresolved placeholder markers or an unspecified "add tests" instruction; every task names files and commands. +- Type consistency: graph status aliases live in `status_conventions.py`, evaluation status in `evaluation_summary.py`, mutation payload body in `graph_dto.py`, and cancel-cause aliases in `task_events.py` throughout the plan. From 89b50b233805a19d1efb6102e3e2bfd46ef78e18 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:21:32 +0100 Subject: [PATCH 14/66] refactor: narrow public API surface Move runtime DTOs and protocols to their core homes so internal packages no longer depend on public API facades. Made-with: Cursor --- .../researchrubrics/judge_criterion.py | 220 ++++++++++++++---- .../benchmarks/swebench_verified/criterion.py | 2 +- .../common/llm_context/adapters/base.py | 2 +- .../llm_context/adapters/pydantic_ai.py | 95 +++++--- .../ergon_builtins/models/resolution.py | 44 +++- .../tools/graph_toolkit_types.py | 2 +- .../workers/baselines/react_worker.py | 106 ++++++--- .../workers/baselines/training_stub_worker.py | 2 +- .../research_rubrics/researcher_worker.py | 58 +++-- .../workflow_cli_react_worker.py | 94 +++++--- ergon_cli/ergon_cli/commands/benchmark.py | 10 +- ergon_cli/ergon_cli/commands/workflow.py | 86 +++++-- ergon_core/ergon_core/api/__init__.py | 9 - ergon_core/ergon_core/api/benchmark.py | 5 +- ergon_core/ergon_core/api/criterion.py | 23 +- .../ergon_core/api/criterion_runtime.py | 91 -------- .../ergon_core/api/evaluation_context.py | 2 +- ergon_core/ergon_core/api/evaluator.py | 2 +- ergon_core/ergon_core/api/types.py | 16 -- ergon_core/ergon_core/api/worker.py | 4 +- .../ergon_core/{api => core}/generation.py | 41 +--- .../ergon_core/{api => core}/json_types.py | 2 +- .../core/persistence/shared/enums.py | 31 +++ .../core/persistence/telemetry/models.py | 57 +---- .../providers/sandbox/resource_publisher.py | 4 +- .../{api => core/runtime}/dependencies.py | 2 +- .../runtime/evaluation/criterion_runtime.py | 52 +++-- .../core/runtime/evaluation/protocols.py | 69 ++++++ .../runtime/resources.py} | 12 +- .../core/runtime/services/workflow_service.py | 13 +- .../smoke_fixtures/smoke_base/leaf_base.py | 7 +- .../smoke_fixtures/smoke_base/recursive.py | 6 +- .../smoke_fixtures/smoke_base/worker_base.py | 3 +- .../swebench_verified/test_criterion.py | 10 +- tests/unit/api/test_public_api_imports.py | 36 +++ .../test_public_api_boundaries.py | 32 +++ .../test_minif2f_proof_verification.py | 3 +- .../test_swebench_criterion_patch_source.py | 3 +- .../common/test_transcript_adapters.py | 62 ++++- tests/unit/runtime/test_workflow_service.py | 7 +- tests/unit/state/test_criterion_runtime_di.py | 105 ++++++++- .../unit/state/test_generation_turn_build.py | 12 +- .../test_swebench_criterion_no_sandbox.py | 5 +- 43 files changed, 969 insertions(+), 478 deletions(-) delete mode 100644 ergon_core/ergon_core/api/criterion_runtime.py delete mode 100644 ergon_core/ergon_core/api/types.py rename ergon_core/ergon_core/{api => core}/generation.py (54%) rename ergon_core/ergon_core/{api => core}/json_types.py (78%) rename ergon_core/ergon_core/{api => core/runtime}/dependencies.py (87%) create mode 100644 ergon_core/ergon_core/core/runtime/evaluation/protocols.py rename ergon_core/ergon_core/{api/run_resource.py => core/runtime/resources.py} (85%) create mode 100644 tests/unit/api/test_public_api_imports.py create mode 100644 tests/unit/architecture/test_public_api_boundaries.py diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py index bd0f1cfb..ea2a6583 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py @@ -2,14 +2,27 @@ from ergon_core.api.criterion import Criterion from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult +from ergon_core.api.results import ( + CriterionObservation, + CriterionObservationMessage, + CriterionResult, + CriterionScoreSpec, +) +from ergon_core.core.runtime.resources import RunResourceKind, RunResourceView +from pydantic import BaseModel + +from ergon_builtins.benchmarks.researchrubrics.task_schemas import RubricCriterion from ergon_builtins.common.llm.structured_judge import ( JudgeMessage, call_structured_judge, ) -from pydantic import BaseModel -from ergon_builtins.benchmarks.researchrubrics.task_schemas import RubricCriterion + +class _ResourceEvidence(BaseModel): + model_config = {"frozen": True, "arbitrary_types_allowed": True} + + resource: RunResourceView + text: str class ResearchRubricsVerdict(BaseModel): @@ -25,54 +38,183 @@ class ResearchRubricsJudgeCriterion(Criterion): def __init__( self, *, - name: str, + slug: str, rubric: RubricCriterion, model: str = "openai:gpt-4o", ) -> None: - super().__init__(name=name, weight=rubric.weight) + super().__init__( + slug=slug, + description=rubric.criterion, + weight=rubric.weight, + score_spec=CriterionScoreSpec(max_score=abs(rubric.weight)), + ) self.rubric = rubric - self.max_score = abs(rubric.weight) self.model = model - self.system_prompt = _build_system_prompt(rubric) + self.system_prompt = self._build_system_prompt(rubric) async def evaluate(self, context: EvaluationContext) -> CriterionResult: - verdict = await call_structured_judge( - messages=[ - JudgeMessage(role="system", content=self.system_prompt), - JudgeMessage(role="user", content=_build_user_prompt(context)), - ], - response_type=ResearchRubricsVerdict, - model=self.model, + final_outputs, scratch_outputs = await self._load_researchrubrics_evidence(context) + user_prompt = self._build_user_prompt( + context, + final_outputs=final_outputs, + scratch_outputs=scratch_outputs, ) + verdict = await self._call_judge( + system_prompt=self.system_prompt, + user_prompt=user_prompt, + ) + evaluated_resource_ids = [ + str(evidence.resource.id) for evidence in [*final_outputs, *scratch_outputs] + ] return CriterionResult( - name=self.name, - score=self.max_score if verdict.passed else 0.0, + slug=self.slug, + score=self.score_spec.max_score if verdict.passed else 0.0, passed=verdict.passed, weight=self.weight, + max_score=self.score_spec.max_score, feedback=verdict.reasoning, + model_reasoning=verdict.reasoning, + evaluation_input=user_prompt, + evaluated_resource_ids=evaluated_resource_ids, + observation=self._build_observation( + system_prompt=self.system_prompt, + user_prompt=user_prompt, + verdict=verdict, + evaluated_resource_ids=evaluated_resource_ids, + final_outputs=final_outputs, + ), ) + def _build_observation( + self, + *, + system_prompt: str, + user_prompt: str, + verdict: ResearchRubricsVerdict, + evaluated_resource_ids: list[str], + final_outputs: list[_ResourceEvidence], + ) -> CriterionObservation: + return CriterionObservation( + prompt_messages=[ + CriterionObservationMessage(role="system", content=system_prompt), + CriterionObservationMessage(role="user", content=user_prompt), + ], + evidence_resource_ids=evaluated_resource_ids, + output=verdict.model_dump(mode="json"), + model=self.model, + details={ + "axis": self.rubric.axis, + "rubric_weight": self.rubric.weight, + "primary_evidence": ( + f"run_resource:{final_outputs[0].resource.name}" + if final_outputs + else "worker_result.output" + ), + }, + ) -def _build_system_prompt(criterion: RubricCriterion) -> str: - axis_context = ( - f"\n\nThis criterion belongs to the ResearchRubrics '{criterion.axis}' axis." - if criterion.axis - else "" - ) - weight_note = f"\n\nResearchRubrics weight: {criterion.weight}" - return ( - "You are an expert ResearchRubrics evaluator assessing deep-research reports.\n\n" - "Evaluate whether the report satisfies this exact rubric criterion:\n" - f"{criterion.criterion}{axis_context}{weight_note}\n\n" - "Use the original research request, the agent's reasoning when present, " - "and the final report/output as evidence. Return a binary verdict: " - "`passed=true` only when the criterion is clearly satisfied. Explain the " - "decision with concrete evidence from the provided material." - ) - - -def _build_user_prompt(context: EvaluationContext) -> str: - return ( - f"Original research request:\n{context.task.description}\n\n" - f"Researcher output:\n{context.worker_result.output}" - ) + async def _call_judge( + self, + *, + system_prompt: str, + user_prompt: str, + ) -> ResearchRubricsVerdict: + return await call_structured_judge( + messages=[ + JudgeMessage(role="system", content=system_prompt), + JudgeMessage(role="user", content=user_prompt), + ], + response_type=ResearchRubricsVerdict, + model=self.model, + ) + + @classmethod + async def _load_researchrubrics_evidence( + cls, + context: EvaluationContext, + ) -> tuple[list[_ResourceEvidence], list[_ResourceEvidence]]: + if context.runtime is None: + return [], [] + + resources = await context.runtime.list_resources() + evidence: list[_ResourceEvidence] = [] + for resource in resources: + try: + raw_content = await context.runtime.read_resource_by_id(resource.id) + except OSError as exc: + text = f"[Unable to read resource {resource.id}: {exc}]" + else: + text = raw_content.decode("utf-8", errors="replace") + evidence.append(_ResourceEvidence(resource=resource, text=text)) + + final_outputs = [item for item in evidence if cls._is_final_output_resource(item.resource)] + scratch_outputs = [ + item for item in evidence if not cls._is_final_output_resource(item.resource) + ] + return final_outputs, scratch_outputs + + @classmethod + def _is_final_output_resource(cls, resource: RunResourceView) -> bool: + sandbox_origin = str(resource.metadata.get("sandbox_origin") or "") + return resource.kind == RunResourceKind.REPORT or sandbox_origin.startswith( + "/workspace/final_output/" + ) + + @classmethod + def _format_resource_section( + cls, + title: str, + evidence: list[_ResourceEvidence], + ) -> str: + if not evidence: + return f"{title}:\n(none)" + + parts = [f"{title}:"] + for idx, item in enumerate(evidence, start=1): + resource = item.resource + sandbox_origin = resource.metadata.get("sandbox_origin") + provenance = ( + f"id={resource.id}; name={resource.name}; kind={resource.kind.value}; " + f"sandbox_origin={sandbox_origin or '(unknown)'}" + ) + parts.append(f"\n[{idx}] {provenance}\n{item.text}") + return "\n".join(parts) + + @classmethod + def _build_system_prompt(cls, criterion: RubricCriterion) -> str: + axis_context = ( + f"\n\nThis criterion belongs to the ResearchRubrics '{criterion.axis}' axis." + if criterion.axis + else "" + ) + weight_note = f"\n\nResearchRubrics weight: {criterion.weight}" + return ( + "You are an expert ResearchRubrics evaluator assessing deep-research reports.\n\n" + "Evaluate whether the report satisfies this exact rubric criterion:\n" + f"{criterion.criterion}{axis_context}{weight_note}\n\n" + "Judge the final output resources first. Use scratch/supporting resources " + "only as secondary context, and use the final assistant message only as a " + "status summary. Return a binary verdict: `passed=true` only when the " + "criterion is clearly satisfied. Explain the decision with concrete " + "evidence from the provided material." + ) + + @classmethod + def _build_user_prompt( + cls, + context: EvaluationContext, + *, + final_outputs: list[_ResourceEvidence], + scratch_outputs: list[_ResourceEvidence], + ) -> str: + return "\n\n".join( + [ + f"Original research request:\n{context.task.description}", + cls._format_resource_section("Final output resources", final_outputs), + cls._format_resource_section( + "Scratch / supporting resources", + scratch_outputs, + ), + f"Final assistant message:\n{context.worker_result.output}", + ] + ) diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py index 32261aa6..f6528ba1 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py @@ -20,9 +20,9 @@ from typing import Any, ClassVar from ergon_core.api.criterion import Criterion -from ergon_core.api.criterion_runtime import CriterionRuntime from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.api.results import CriterionResult +from ergon_core.core.runtime.evaluation.protocols import CriterionRuntime from ergon_builtins.benchmarks.swebench_verified.sandbox_manager_support import ( payload_to_swebench_row as _payload_to_swebench_row, diff --git a/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py b/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py index 35e70575..2cb7f599 100644 --- a/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py +++ b/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py @@ -2,7 +2,7 @@ from typing import Protocol, TypeVar -from ergon_core.api.generation import GenerationTurn +from ergon_core.core.generation import GenerationTurn from ergon_core.core.persistence.context.models import RunContextEvent TranscriptT = TypeVar("TranscriptT") diff --git a/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py b/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py index c5292c09..6fb6b554 100644 --- a/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py +++ b/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py @@ -1,7 +1,8 @@ """PydanticAI transcript adapter.""" import json -from ergon_core.api.generation import ( + +from ergon_core.core.generation import ( GenerationTurn, ModelRequestPart as ErgonModelRequestPart, ModelResponsePart as ErgonModelResponsePart, @@ -22,14 +23,14 @@ UserMessagePayload, ) from ergon_core.core.persistence.context.models import RunContextEvent -from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse +from pydantic import BaseModel +from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse, ToolReturnContent from pydantic_ai.messages import ModelRequestPart as PydanticModelRequestPart from pydantic_ai.messages import ModelResponsePart as PydanticModelResponsePart from pydantic_ai.messages import SystemPromptPart as PydanticSystemPromptPart from pydantic_ai.messages import TextPart as PydanticTextPart from pydantic_ai.messages import ThinkingPart as PydanticThinkingPart from pydantic_ai.messages import ToolCallPart as PydanticToolCallPart -from pydantic_ai.messages import ToolReturnContent from pydantic_ai.messages import ToolReturnPart as PydanticToolReturnPart from pydantic_ai.messages import UserPromptPart as PydanticUserPromptPart from pydantic_core import to_jsonable_python @@ -37,35 +38,38 @@ from ergon_builtins.common.llm_context.adapters.base import TranscriptAdapter +class TranscriptTurnCursor(BaseModel): + """Track how many turns have already been emitted from a growing transcript.""" + + model_config = {"validate_assignment": True} + + emitted_turn_count: int = 0 + + class PydanticAITranscriptAdapter(TranscriptAdapter[list[ModelMessage], list[ModelMessage]]): """Convert complete PydanticAI message histories into Ergon turns.""" - def build_turns(self, transcript: list[ModelMessage]) -> list[GenerationTurn]: + def build_turns( + self, + transcript: list[ModelMessage], + *, + flush_pending: bool = True, + ) -> list[GenerationTurn]: """Build turns from a complete PydanticAI message list.""" - turns: list[GenerationTurn] = [] - pending_response: ModelResponse | None = None - pending_request_in: ModelRequest | None = None - - for message in transcript: - if isinstance(message, ModelRequest): - if pending_response is not None: - turns.append( - _to_turn( - pending_request_in, - pending_response, - tool_result_request=message, - ) - ) - pending_response = None - pending_request_in = None - pending_request_in = message - elif isinstance(message, ModelResponse): - pending_response = message - - if pending_response is not None: - turns.append(_to_turn(pending_request_in, pending_response, tool_result_request=None)) + return _build_turns_from_transcript(transcript, flush_pending=flush_pending) - return turns + def build_new_turns( + self, + transcript: list[ModelMessage], + cursor: TranscriptTurnCursor, + *, + flush_pending: bool = False, + ) -> list[GenerationTurn]: + """Return turns not previously emitted for a growing transcript.""" + turns = _build_turns_from_transcript(transcript, flush_pending=flush_pending) + new_turns = turns[cursor.emitted_turn_count :] + cursor.emitted_turn_count = len(turns) + return new_turns def assemble_replay(self, events: list[RunContextEvent]) -> list[ModelMessage]: """Reconstruct PydanticAI messages from ordered context events.""" @@ -92,6 +96,37 @@ def assemble_replay(self, events: list[RunContextEvent]) -> list[ModelMessage]: return messages +def _build_turns_from_transcript( + transcript: list[ModelMessage], + *, + flush_pending: bool, +) -> list[GenerationTurn]: + turns: list[GenerationTurn] = [] + pending_response: ModelResponse | None = None + pending_request_in: ModelRequest | None = None + + for message in transcript: + if isinstance(message, ModelRequest): + if pending_response is not None: + turns.append( + _to_turn( + pending_request_in, + pending_response, + tool_result_request=message, + ) + ) + pending_response = None + pending_request_in = None + pending_request_in = message + elif isinstance(message, ModelResponse): + pending_response = message + + if pending_response is not None and flush_pending: + turns.append(_to_turn(pending_request_in, pending_response, tool_result_request=None)) + + return turns + + def _to_turn( request_in: ModelRequest | None, response: ModelResponse, @@ -120,7 +155,11 @@ def extract_logprobs(response: ModelResponse) -> list[TokenLogprob] | None: token = entry.get("token") logprob = entry.get("logprob") top_logprobs = entry.get("top_logprobs", []) - if isinstance(token, str) and isinstance(logprob, int | float) and isinstance(top_logprobs, list): + if ( + isinstance(token, str) + and isinstance(logprob, int | float) + and isinstance(top_logprobs, list) + ): logprobs.append( TokenLogprob( token=token, diff --git a/ergon_builtins/ergon_builtins/models/resolution.py b/ergon_builtins/ergon_builtins/models/resolution.py index ba2e0492..db42b3eb 100644 --- a/ergon_builtins/ergon_builtins/models/resolution.py +++ b/ergon_builtins/ergon_builtins/models/resolution.py @@ -3,13 +3,16 @@ import logging from collections.abc import Callable -from ergon_core.api.json_types import JsonObject import pydantic_ai.models +from ergon_core.core.json_types import JsonObject from pydantic import BaseModel +from pydantic_ai.models.openrouter import OpenRouterReasoning logger = logging.getLogger(__name__) _ANTHROPIC_THINKING_BUDGET_TOKENS = 1024 +_OPENROUTER_ANTHROPIC_SONNET_BUDGET_TOKENS = 4096 +_OPENROUTER_ANTHROPIC_OPUS_BUDGET_TOKENS = 8192 _OPENAI_COMPAT_LOGPROB_SETTINGS: JsonObject = { "openai_logprobs": True, "openai_top_logprobs": 1, @@ -52,6 +55,15 @@ def capture_model_settings_for( return dict(_OPENAI_COMPAT_LOGPROB_SETTINGS) if prefix == "anthropic": + anthropic_model_name = (model_target or "").split(":", 1)[-1].lower() + if anthropic_model_name.startswith("claude-opus-4.7"): + return { + "anthropic_thinking": { + "type": "adaptive", + "display": "summarized", + }, + "anthropic_effort": "medium", + } return { "anthropic_thinking": { "type": "enabled", @@ -61,10 +73,13 @@ def capture_model_settings_for( if prefix == "openrouter": return { - "openrouter_reasoning": { - "enabled": True, - "exclude": False, - } + "openrouter_reasoning": _openrouter_reasoning_settings_for(model_target), + } + + if prefix == "openai-responses": + return { + "openai_reasoning_effort": "medium", + "openai_reasoning_summary": "detailed", } if prefix == "google": @@ -77,6 +92,25 @@ def capture_model_settings_for( return None +def _openrouter_reasoning_settings_for(model_target: str | None) -> OpenRouterReasoning: + model_name = (model_target or "").split(":", 1)[-1].lower() + if model_name.startswith("anthropic/claude-opus-4"): + return OpenRouterReasoning( + max_tokens=_OPENROUTER_ANTHROPIC_OPUS_BUDGET_TOKENS, + exclude=False, + ) + if model_name.startswith("anthropic/claude-sonnet-4"): + return OpenRouterReasoning( + max_tokens=_OPENROUTER_ANTHROPIC_SONNET_BUDGET_TOKENS, + exclude=False, + ) + if model_name.startswith("openai/gpt-5"): + return OpenRouterReasoning(effort="medium", exclude=False) + if model_name.startswith(("google/gemini-3", "moonshotai/kimi-k2")): + return OpenRouterReasoning(effort="medium", exclude=False) + return OpenRouterReasoning(enabled=True, exclude=False) + + def _with_capture_settings(target: str, resolved: ResolvedModel) -> ResolvedModel: settings = capture_model_settings_for(target, supports_logprobs=resolved.supports_logprobs) if resolved.capture_model_settings == settings: diff --git a/ergon_builtins/ergon_builtins/tools/graph_toolkit_types.py b/ergon_builtins/ergon_builtins/tools/graph_toolkit_types.py index ba1b107c..a4412fc3 100644 --- a/ergon_builtins/ergon_builtins/tools/graph_toolkit_types.py +++ b/ergon_builtins/ergon_builtins/tools/graph_toolkit_types.py @@ -7,7 +7,7 @@ from datetime import datetime from uuid import UUID -from ergon_core.api import RunResourceKind, RunResourceView +from ergon_core.core.runtime.resources import RunResourceKind, RunResourceView from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution from pydantic import BaseModel, ConfigDict, Field diff --git a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py index f53efaa7..752438ec 100644 --- a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py @@ -7,18 +7,22 @@ from typing import Any, Self from uuid import UUID -from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter -from ergon_builtins.models.resolution import resolve_model_target -from ergon_core.api import BenchmarkTask, Tool, Worker, WorkerContext, WorkerOutput -from ergon_core.api.generation import GenerationTurn +from ergon_core.api import BenchmarkTask, Worker, WorkerContext, WorkerOutput +from ergon_core.core.generation import GenerationTurn from ergon_core.core.persistence.context.repository import ContextEventRepository from ergon_core.core.persistence.shared.db import get_session - from pydantic import BaseModel from pydantic_ai import Agent from pydantic_ai.messages import ModelMessage from sqlmodel import Session +from ergon_builtins.common.llm_context.adapters.pydantic_ai import ( + PydanticAITranscriptAdapter, + TranscriptTurnCursor, +) +from ergon_builtins.models.resolution import resolve_model_target +from ergon_builtins.observability.pydantic_ai_logfire import configure_pydantic_ai_logfire + logger = logging.getLogger(__name__) @@ -50,12 +54,12 @@ def __init__( model: str | None, task_id: UUID, sandbox_id: str, - tools: list[Tool], + tools: list[Any], system_prompt: str | None, max_iterations: int, ) -> None: super().__init__(name=name, model=model, task_id=task_id, sandbox_id=sandbox_id) - self.tools: list[Tool] = tools + self.tools: list[Any] = tools self.system_prompt: str | None = system_prompt self.max_iterations: int = max_iterations self._seed_messages: list[ModelMessage] | None = None @@ -66,49 +70,86 @@ async def execute( *, context: WorkerContext, ) -> AsyncGenerator[GenerationTurn, None]: - async for turn in self._run_agent(task): + async for turn in self._run_agent(task, context): yield turn + def build_agent_deps( + self, context: WorkerContext + ) -> Any | None: # slopcop: ignore[no-typing-any] + return None + async def _run_agent( self, task: BenchmarkTask, + context: WorkerContext, ) -> AsyncGenerator[GenerationTurn, None]: """Run the underlying pydantic-ai agent and yield the turns it produced.""" resolved = resolve_model_target(self.model) + configure_pydantic_ai_logfire() + agent_deps = self.build_agent_deps(context) + deps_type = type(agent_deps) if agent_deps is not None else None - agent: Agent[None, _AgentOutput] = Agent( + agent: Agent[Any, _AgentOutput] = Agent( model=resolved.model, instructions=self.system_prompt or None, tools=self.tools, output_type=_AgentOutput, + deps_type=deps_type, ) task_prompt = _format_task(task) node_count = 0 + adapter = PydanticAITranscriptAdapter() + cursor = TranscriptTurnCursor() + run = None - async with agent.iter( - task_prompt, - model_settings=resolved.capture_model_settings, - message_history=self._seed_messages, - ) as run: - async for _node in run: - node_count += 1 - if node_count >= self.max_iterations: - logger.warning( - "ReActWorker hit max_iterations=%d; persisting partial turns", - self.max_iterations, - ) - break - - # Build all turns from the complete message history after the run. - # Using ctx.state.message_history (not incremental slices) ensures tool_results - # are correctly paired with their generating ModelResponse. - # Works for both complete and partial (max_iterations) runs — - # pydantic-ai 0.7.x moved all_messages() to AgentRunResult, but - # ctx.state.message_history is always populated incrementally. - turns = PydanticAITranscriptAdapter().build_turns(run.ctx.state.message_history) - for turn in turns: - yield turn + try: + async with agent.iter( + task_prompt, + model_settings=resolved.capture_model_settings, + message_history=self._seed_messages, + deps=agent_deps, + ) as active_run: + run = active_run + async for _node in run: + node_count += 1 + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=False, + ): + yield turn + if node_count >= self.max_iterations: + logger.warning( + "ReActWorker hit max_iterations=%d; persisting partial turns", + self.max_iterations, + ) + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=True, + ): + yield turn + raise RuntimeError( + f"ReActWorker exceeded max_iterations={self.max_iterations}" + ) + except Exception: # slopcop: ignore[no-broad-except] + if run is not None: + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=True, + ): + yield turn + raise + + if run is not None: + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=True, + ): + yield turn def get_output(self, context: WorkerContext) -> WorkerOutput: """Extract the agent's text output from the last context event.""" @@ -126,6 +167,7 @@ def _base_output(self, context: WorkerContext) -> WorkerOutput: with get_session() as session: repo = ContextEventRepository() events = repo.get_for_execution(session, context.execution_id) + turn_ids: set[str] = set() for e in events: payload = e.parsed_payload() diff --git a/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py b/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py index 459351c8..0f41657d 100644 --- a/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py +++ b/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py @@ -15,7 +15,7 @@ from uuid import UUID from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.api.generation import ( +from ergon_core.core.generation import ( GenerationTurn, ModelRequestPart, ModelResponsePart, diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py index d766ee39..3c1b981a 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py @@ -5,20 +5,19 @@ ReActWorker.execute(). """ -from collections.abc import AsyncGenerator import time +from collections.abc import AsyncGenerator from typing import ClassVar from uuid import UUID -from ergon_core.api import RunResourceView -from ergon_core.api.generation import GenerationTurn +from ergon_core.core.generation import GenerationTurn +from ergon_core.core.runtime.resources import RunResourceView from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext from ergon_core.core.providers.sandbox.research_rubrics_manager import ( ResearchRubricsSandboxManager, ) -from ergon_builtins.tools.graph_toolkit import ResearchGraphToolkit from ergon_builtins.benchmarks.researchrubrics.toolkit_types import ( ReportReadFailure, ReportReadResponse, @@ -27,10 +26,15 @@ ReportWriteResponse, ReportWriteSuccess, ) +from ergon_builtins.tools.graph_toolkit import ResearchGraphToolkit from ergon_builtins.tools.research_rubrics_toolkit import ( ResearchRubricsToolkit, ) from ergon_builtins.workers.baselines.react_worker import ReActWorker +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetState, +) from ergon_builtins.workers.research_rubrics._run_skill import ( ReportEditSkillRequest, ReportReadSkillRequest, @@ -41,23 +45,30 @@ ) _RESEARCHER_SYSTEM_PROMPT = ( - "You are a research agent. Your job is to investigate a research question " - "using web search and produce a well-sourced report.\n\n" - "You have access to:\n" - "- exa_search: Search the web for relevant sources\n" - "- exa_qa: Ask Exa a direct question\n" - "- exa_get_content: Extract full text from a URL\n" - "- write_report_draft: Write a markdown report draft\n" - "- edit_report_draft: Edit an existing draft\n" - "- read_report_draft: Read a draft file\n" - "- Resource discovery tools to observe peer outputs\n\n" - "Write your final report to 'final_output/report.md' using write_report_draft. " - "Include a # Findings section and a ## Sources section with citations. " - "For scoped child tasks, keep the evidence pass bounded: use at most six " - "search/QA/content tool calls total, then write a concise report and call " - "final_result. Do not keep searching indefinitely." + "Role: You are a focused ResearchRubrics research agent.\n\n" + "Goal: Produce `final_output/report.md` with a concise, well-sourced answer " + "to your scoped task. Include a # Findings section and a ## Sources section " + "with citations.\n\n" + "Tools:\n" + "- `exa_search`: broad web search for candidate sources.\n" + "- `exa_qa`: focused Q&A when one specific fact or synthesis is missing.\n" + "- `exa_get_content`: read a specific URL that looks important.\n" + "- `write_report_draft` / `edit_report_draft` / `read_report_draft`: create, " + "revise, and inspect markdown report files.\n" + "- Resource discovery tools: inspect outputs from this task, peer tasks, " + "children, descendants, or the run.\n\n" + "Stop rules: You have a limited non-workflow tool budget. Use the minimum " + "evidence sufficient to answer correctly, then stop searching and write the " + "report. Search again only if a required fact/source is missing. If any tool " + "returns TOOL_BUDGET_EXHAUSTED, immediately write the best possible report " + "from the context already gathered." ) +_TOOL_BUDGET_LIMITS = { + "max_workflow_tool_calls": 12, + "max_other_tool_calls": 12, +} + def _workspace_path(relative_path: str) -> str: """Resolve a user path under /workspace and reject traversal.""" @@ -94,6 +105,12 @@ def __init__( system_prompt=_RESEARCHER_SYSTEM_PROMPT, max_iterations=60, ) + self._agent_deps = AgentToolBudgetDeps( + tool_budget=AgentToolBudgetState(**_TOOL_BUDGET_LIMITS), + ) + + def build_agent_deps(self, context: WorkerContext) -> AgentToolBudgetDeps: + return self._agent_deps async def execute( self, @@ -136,6 +153,9 @@ async def publisher_sync() -> list[RunResourceView]: ) graph_tools = graph_toolkit.build_tools() + self._agent_deps = AgentToolBudgetDeps( + tool_budget=AgentToolBudgetState(**_TOOL_BUDGET_LIMITS), + ) self.tools = [*rr_tools, *graph_tools] async for turn in super().execute(task, context=context): diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py index 648b29ce..7a7a3a05 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py @@ -1,10 +1,10 @@ -from collections.abc import AsyncGenerator import time +from collections.abc import AsyncGenerator from typing import ClassVar from uuid import UUID -from ergon_core.api import RunResourceView -from ergon_core.api.generation import GenerationTurn +from ergon_core.core.generation import GenerationTurn +from ergon_core.core.runtime.resources import RunResourceView from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext from ergon_core.core.providers.sandbox.research_rubrics_manager import ( @@ -23,6 +23,10 @@ from ergon_builtins.tools.research_rubrics_toolkit import ResearchRubricsToolkit from ergon_builtins.tools.workflow_cli_tool import make_workflow_cli_tool from ergon_builtins.workers.baselines.react_worker import ReActWorker +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetState, +) from ergon_builtins.workers.research_rubrics._run_skill import ( ReportEditSkillRequest, ReportReadSkillRequest, @@ -33,40 +37,54 @@ ) _WORKFLOW_PROMPT = ( - "You are a research agent. Your job is to investigate a research question " - "using web search and produce a well-sourced report.\n\n" - "You have access to:\n" - "- exa_search: Search the web for relevant sources\n" - "- exa_qa: Ask Exa a direct question\n" - "- exa_get_content: Extract full text from a URL\n" - "- write_report_draft: Write a markdown report draft\n" - "- edit_report_draft: Edit an existing draft\n" - "- read_report_draft: Read a draft file\n" - "- workflow: Inspect current-run task topology/resources and manage subtasks\n\n" - "Write your final report to 'final_output/report.md' using write_report_draft. " + "Role: You are a recursive ResearchRubrics research agent with workflow access.\n\n" + "Goal: Produce `final_output/report.md` with a well-sourced answer to the task. " "Include a # Findings section and a ## Sources section with citations.\n\n" - "Use workflow(command) to inspect this run before " - "deciding what context is missing. Useful commands include: " + "Tools:\n" + "- `workflow(command)`: inspect task topology/resources and create subtasks. " + "Use it deliberately; workflow calls are limited. Useful commands include " "`inspect task-tree`, `inspect resource-list --scope input`, " - "`inspect resource-list --scope visible --limit 20`, " - "`inspect next-actions`, and " - "`manage materialize-resource --resource-id --dry-run`. " - "For ResearchRubrics benchmark tasks, start by creating at least one real " - "child research subtask unless the request is truly trivial. First dry-run " - "the shape, then create focused children with commands like: " - "`manage add-task --task-slug source-scout --worker researchrubrics-researcher " - "--description 'Find high-quality sources for ...' --dry-run`, then repeat " - "without `--dry-run` once the command is correct. Use worker " - "`researchrubrics-researcher` for child research tasks, and use " - "`researchrubrics-workflow-cli-react` only when a child should itself be " - "manager-capable. After creating children, do not duplicate their research " - "yourself; use `inspect task-tree --wait-seconds 60` until children are terminal, then inspect " - "`resource-list --scope children` and use their reports as evidence before " - "composing the final report. " - "Use `--format json` when you need stable IDs. Resource copies are snapshots: " - "materialized files become resources owned by this task, not edits to the source." + "`inspect resource-list --scope visible --limit 20`, `inspect next-actions`, " + "and `manage materialize-resource --resource-id --dry-run`.\n" + "- `exa_search`: broad web search for candidate sources.\n" + "- `exa_qa`: focused Q&A when one specific fact or synthesis is missing.\n" + "- `exa_get_content`: read a specific URL that looks important.\n" + "- `write_report_draft` / `edit_report_draft` / `read_report_draft`: create, " + "revise, and inspect markdown report files.\n" + "- Resource discovery tools: inspect resources produced by this task, children, " + "descendants, or the run.\n\n" + "Task graph policy: At the start of your task, use workflow context before " + "deep research: `inspect task-tree --format json` and " + "`inspect next-actions --manager-capable`. Use that context to decide whether " + "to solve directly or create subtasks. Create subtasks when the work can be " + "parallelized into independent evidence-gathering or checking efforts, such " + "as source scouting, rubric-cluster coverage, factual sections, or risk/negative " + "constraint checks. Do not create subtasks just to avoid writing; if the task " + "is already narrow, answer it directly. Good subtasks have clear deliverables " + "and produce evidence artifacts for synthesis. Prefer a small number of useful " + "subtasks over many tiny ones. Child subtasks should usually use worker " + "`researchrubrics-workflow-cli-react` too, so the same decision policy applies " + "recursively. Use `researchrubrics-researcher` only for a narrow leaf task that " + "should not create further subtasks. First dry-run commands like " + "`manage add-task --task-slug source-scout --worker " + "researchrubrics-workflow-cli-react --description 'Find high-quality sources " + "for ...' --dry-run`, then repeat without `--dry-run` once correct. If you " + "create subtasks, wait for them to finish before final synthesis, then inspect " + "their resources. If a subtask fails or is cancelled, inspect what is missing " + "and decide whether to proceed with available evidence or create one replacement " + "task with a narrower scope.\n\n" + "Stop rules: Use the fewest useful tool loops. Search again only if a required " + "fact/source is missing. Do not search to improve phrasing or collect " + "nonessential detail. If current evidence can answer the core task, write the " + "report. If any tool returns TOOL_BUDGET_EXHAUSTED, stop polling/searching and " + "produce the best possible final output from current context/resources." ) +_TOOL_BUDGET_LIMITS = { + "max_workflow_tool_calls": 12, + "max_other_tool_calls": 12, +} + def _workspace_path(relative_path: str) -> str: cleaned = relative_path.lstrip("/") @@ -95,6 +113,12 @@ def __init__( system_prompt=_WORKFLOW_PROMPT, max_iterations=60, ) + self._agent_deps = AgentToolBudgetDeps( + tool_budget=AgentToolBudgetState(**_TOOL_BUDGET_LIMITS), + ) + + def build_agent_deps(self, context: WorkerContext) -> AgentToolBudgetDeps: + return self._agent_deps async def execute( self, @@ -133,6 +157,10 @@ async def publisher_sync() -> list[RunResourceView]: worker_context=context, sandbox_task_key=self.task_id, benchmark_type="researchrubrics", + budgeted=True, + ) + self._agent_deps = AgentToolBudgetDeps( + tool_budget=AgentToolBudgetState(**_TOOL_BUDGET_LIMITS), ) self.tools = [*rr_toolkit.build_tools(), *graph_toolkit.build_tools(), workflow_tool] diff --git a/ergon_cli/ergon_cli/commands/benchmark.py b/ergon_cli/ergon_cli/commands/benchmark.py index e2420992..72d692fb 100644 --- a/ergon_cli/ergon_cli/commands/benchmark.py +++ b/ergon_cli/ergon_cli/commands/benchmark.py @@ -13,12 +13,8 @@ import inngest from e2b import Template - -from ergon_cli.composition import build_experiment -from ergon_cli.discovery import list_benchmarks -from ergon_cli.rendering import render_run_result, render_table from ergon_core.api.handles import ExperimentRunHandle -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.shared.db import ensure_db, get_session from ergon_core.core.persistence.shared.enums import TERMINAL_RUN_STATUSES from ergon_core.core.persistence.telemetry.models import RunRecord @@ -28,6 +24,10 @@ from ergon_core.core.runtime.services.run_service import create_run from ergon_core.core.settings import settings +from ergon_cli.composition import build_experiment +from ergon_cli.discovery import list_benchmarks +from ergon_cli.rendering import render_run_result, render_table + class BuildLog(Protocol): def __str__(self) -> str: ... diff --git a/ergon_cli/ergon_cli/commands/workflow.py b/ergon_cli/ergon_cli/commands/workflow.py index 93286a11..98014bad 100644 --- a/ergon_cli/ergon_cli/commands/workflow.py +++ b/ergon_cli/ergon_cli/commands/workflow.py @@ -5,15 +5,21 @@ import json import shlex import time +from collections.abc import Callable from typing import cast from uuid import UUID -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject +from ergon_core.core.persistence.shared.enums import RunResourceKind from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.runtime.services.workflow_service import WorkflowService from pydantic import BaseModel from sqlmodel import Session -from collections.abc import Callable + +_RESOURCE_SCOPES = ("visible", "own", "input", "upstream", "children", "descendants") +_RESOURCE_KINDS = tuple(kind.value for kind in RunResourceKind) +_OUTPUT_FORMATS = ("text", "json") +_DEPENDENCY_DIRECTIONS = ("upstream", "downstream", "both") _FORBIDDEN_CONTEXT_FLAGS = { "--run-id", @@ -50,32 +56,30 @@ def build_workflow_parser() -> argparse.ArgumentParser: inspect = sub.add_parser("inspect") inspect_sub = inspect.add_subparsers(dest="action", required=True) resource_list = inspect_sub.add_parser("resource-list") - resource_list.add_argument("--scope", required=True) - resource_list.add_argument("--kind", default=None) + resource_list.add_argument("--scope", required=True, choices=_RESOURCE_SCOPES) + resource_list.add_argument("--kind", choices=_RESOURCE_KINDS, default=None) resource_list.add_argument("--limit", type=int, default=50) resource_list.add_argument("--max-depth", type=int, default=3) - resource_list.add_argument("--format", choices=["text", "json"], default="text") + resource_list.add_argument("--format", choices=_OUTPUT_FORMATS, default="text") resource_list.add_argument("--explain", action="store_true") resource_content = inspect_sub.add_parser("resource-content") resource_content.add_argument("--resource-id", required=True) resource_content.add_argument("--max-bytes", type=int, default=100_000) - resource_content.add_argument("--format", choices=["text", "json"], default="text") + resource_content.add_argument("--format", choices=_OUTPUT_FORMATS, default="text") task_tree = inspect_sub.add_parser("task-tree") - task_tree.add_argument("--format", choices=["text", "json"], default="text") + task_tree.add_argument("--format", choices=_OUTPUT_FORMATS, default="text") task_tree.add_argument("--parent-node-id", default=None) task_tree.add_argument("--wait-seconds", type=float, default=0) dependencies = inspect_sub.add_parser("task-dependencies") - dependencies.add_argument( - "--direction", choices=["upstream", "downstream", "both"], default="both" - ) - dependencies.add_argument("--format", choices=["text", "json"], default="text") + dependencies.add_argument("--direction", choices=_DEPENDENCY_DIRECTIONS, default="both") + dependencies.add_argument("--format", choices=_OUTPUT_FORMATS, default="text") next_action = inspect_sub.add_parser("next-actions") next_action.add_argument("--manager-capable", action="store_true") - next_action.add_argument("--format", choices=["text", "json"], default="text") + next_action.add_argument("--format", choices=_OUTPUT_FORMATS, default="text") manage = sub.add_parser("manage") manage_sub = manage.add_subparsers(dest="action", required=True) @@ -83,12 +87,12 @@ def build_workflow_parser() -> argparse.ArgumentParser: materialize.add_argument("--resource-id", required=True) materialize.add_argument("--destination", default=None) materialize.add_argument("--dry-run", action="store_true") - materialize.add_argument("--format", choices=["text", "json"], default="text") + materialize.add_argument("--format", choices=_OUTPUT_FORMATS, default="text") for action in ("add-task", "add-edge", "restart-task", "abandon-task"): parser_for_action = manage_sub.add_parser(action) parser_for_action.add_argument("--dry-run", action="store_true") - parser_for_action.add_argument("--format", choices=["text", "json"], default="text") + parser_for_action.add_argument("--format", choices=_OUTPUT_FORMATS, default="text") parser_for_action.add_argument("--reason", default=None) if action == "add-task": parser_for_action.add_argument("--task-slug", required=True) @@ -110,22 +114,32 @@ def execute_workflow_command( argv = shlex.split(command) except ValueError as exc: return WorkflowCommandOutput(stdout="", stderr=str(exc), exit_code=2) - _reject_context_flags(argv) + try: + _reject_context_flags(argv) + except ValueError as exc: + return WorkflowCommandOutput(stdout="", stderr=str(exc), exit_code=2) stderr = io.StringIO() try: with contextlib.redirect_stderr(stderr): args = build_workflow_parser().parse_args(argv) except SystemExit as exc: exit_code = exc.code if isinstance(exc.code, int) else 2 - return WorkflowCommandOutput(stdout="", stderr=stderr.getvalue() or str(exc), exit_code=exit_code) + return WorkflowCommandOutput( + stdout="", + stderr=_parse_error_with_help_hint(stderr.getvalue() or str(exc), argv), + exit_code=exit_code, + ) session = session_factory() try: - if args.group == "inspect": - return _handle_inspect(args, context=context, session=session, service=service) - if args.group == "manage": - return asyncio.run( # slopcop: ignore[no-async-from-sync] -- CLI/tool sync bridge - _handle_manage(args, context=context, session=session, service=service) - ) + try: + if args.group == "inspect": + return _handle_inspect(args, context=context, session=session, service=service) + if args.group == "manage": + return asyncio.run( # slopcop: ignore[no-async-from-sync] -- CLI/tool sync bridge + _handle_manage(args, context=context, session=session, service=service) + ) + except ValueError as exc: + return WorkflowCommandOutput(stdout="", stderr=str(exc), exit_code=2) finally: _close_session(session) raise ValueError(f"unsupported workflow command group: {args.group}") @@ -193,10 +207,11 @@ def _handle_inspect( output_format=args.format, ) if args.action == "resource-content": + resource_id = UUID(args.resource_id) content = service.read_resource_bytes( session, run_id=context.run_id, - resource_id=UUID(args.resource_id), + resource_id=resource_id, max_bytes=args.max_bytes, ) if args.format == "json": @@ -208,7 +223,9 @@ def _handle_inspect( tasks = service.list_tasks(session, run_id=context.run_id, parent_node_id=parent) while args.wait_seconds > 0 and time.monotonic() < deadline: children = [task for task in tasks if task.parent_node_id == context.node_id] - if children and all(task.status in {"completed", "failed", "cancelled"} for task in children): + if children and all( + task.status in {"completed", "failed", "cancelled"} for task in children + ): break time.sleep(2) tasks = service.list_tasks(session, run_id=context.run_id, parent_node_id=parent) @@ -258,6 +275,7 @@ async def _handle_manage( service: WorkflowService, ) -> WorkflowCommandOutput: if args.action == "materialize-resource": + resource_id = UUID(args.resource_id) result = await service.materialize_resource( session, run_id=context.run_id, @@ -265,7 +283,7 @@ async def _handle_manage( current_execution_id=context.execution_id, sandbox_task_key=context.sandbox_task_key, benchmark_type=context.benchmark_type, - resource_id=UUID(args.resource_id), + resource_id=resource_id, destination=args.destination, dry_run=args.dry_run, ) @@ -338,3 +356,21 @@ def _close_session(session: Session) -> None: def _reject_context_flags(argv: list[str]) -> None: if any(arg in _FORBIDDEN_CONTEXT_FLAGS for arg in argv): raise ValueError("scope/context flags are injected by the worker and cannot be supplied") + + +def _parse_error_with_help_hint(stderr: str, argv: list[str]) -> str: + command_path = _help_command_path(argv) + hint = f"Run '{command_path} --help' for more info." + text = stderr.strip() + if hint in text: + return text + return f"{text}\n{hint}" if text else hint + + +def _help_command_path(argv: list[str]) -> str: + path = ["workflow"] + for arg in argv: + if arg.startswith("-"): + break + path.append(arg) + return " ".join(path) diff --git a/ergon_core/ergon_core/api/__init__.py b/ergon_core/ergon_core/api/__init__.py index fe6ad7f0..1efd7c1e 100644 --- a/ergon_core/ergon_core/api/__init__.py +++ b/ergon_core/ergon_core/api/__init__.py @@ -3,16 +3,13 @@ from ergon_core.api.benchmark import Benchmark from ergon_core.api.benchmark_deps import BenchmarkDeps from ergon_core.api.criterion import Criterion -from ergon_core.api.criterion_runtime import CommandResult, CriterionRuntime, SandboxResult from ergon_core.api.errors import CriteriaCheckError, DependencyError from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.api.evaluator import Evaluator, Rubric from ergon_core.api.experiment import Experiment from ergon_core.api.handles import ExperimentRunHandle, PersistedExperimentDefinition from ergon_core.api.results import CriterionResult, TaskEvaluationResult, WorkerOutput -from ergon_core.api.run_resource import RunResourceKind, RunResourceView from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.api.types import Tool from ergon_core.api.worker import Worker from ergon_core.api.worker_context import WorkerContext from ergon_core.api.worker_spec import WorkerSpec @@ -21,11 +18,9 @@ "Benchmark", "BenchmarkDeps", "BenchmarkTask", - "CommandResult", "Criterion", "CriterionResult", "CriteriaCheckError", - "CriterionRuntime", "DependencyError", "EvaluationContext", "Evaluator", @@ -34,11 +29,7 @@ "EmptyTaskPayload", "PersistedExperimentDefinition", "Rubric", - "RunResourceKind", - "RunResourceView", - "SandboxResult", "TaskEvaluationResult", - "Tool", "Worker", "WorkerContext", "WorkerOutput", diff --git a/ergon_core/ergon_core/api/benchmark.py b/ergon_core/ergon_core/api/benchmark.py index df675d52..e8869c41 100644 --- a/ergon_core/ergon_core/api/benchmark.py +++ b/ergon_core/ergon_core/api/benchmark.py @@ -10,11 +10,10 @@ from collections.abc import Mapping, Sequence from typing import Any, ClassVar -from pydantic import BaseModel - -from ergon_core.api.dependencies import check_packages from ergon_core.api.errors import DependencyError from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload +from ergon_core.core.runtime.dependencies import check_packages +from pydantic import BaseModel class Benchmark(ABC): diff --git a/ergon_core/ergon_core/api/criterion.py b/ergon_core/ergon_core/api/criterion.py index 11faf242..5724366f 100644 --- a/ergon_core/ergon_core/api/criterion.py +++ b/ergon_core/ergon_core/api/criterion.py @@ -4,10 +4,10 @@ from collections.abc import Mapping from typing import Any, ClassVar -from ergon_core.api.dependencies import check_packages from ergon_core.api.errors import DependencyError from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult +from ergon_core.api.results import CriterionResult, CriterionScoreSpec +from ergon_core.core.runtime.dependencies import check_packages class Criterion(ABC): @@ -23,14 +23,29 @@ class Criterion(ABC): def __init__( self, *, - name: str, + slug: str | None = None, + name: str | None = None, + description: str | None = None, weight: float = 1.0, + score_spec: CriterionScoreSpec | None = None, metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] ) -> None: - self.name = name + resolved_slug = slug or name + if resolved_slug is None: + raise ValueError("Criterion requires a slug") + self.slug = resolved_slug + # Compatibility alias for older criteria/tests while callers migrate. + self.name = resolved_slug + self.description = description or resolved_slug self.weight = weight + self.score_spec = score_spec or CriterionScoreSpec() self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] + @property + def max_score(self) -> float: + """Compatibility alias for the criterion-local score ceiling.""" + return self.score_spec.max_score + @abstractmethod async def evaluate( self, diff --git a/ergon_core/ergon_core/api/criterion_runtime.py b/ergon_core/ergon_core/api/criterion_runtime.py deleted file mode 100644 index 350f14b8..00000000 --- a/ergon_core/ergon_core/api/criterion_runtime.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Public Protocol for the criterion runtime + its small result DTOs. - -``CriterionRuntime`` is the capabilities surface criteria use to interact -with the sandbox while they evaluate. Lives in ``api/`` so -that ``EvaluationContext`` (also in ``api/``) can type it without dragging -in the core runtime package (which would cause a circular import). -""" - -from typing import TYPE_CHECKING, Protocol - -from pydantic import BaseModel, Field - -if TYPE_CHECKING: - from sqlmodel import Session - - from ergon_core.api.run_resource import RunResourceView - from ergon_core.core.providers.sandbox.event_sink import SandboxEventSink - -__all__ = ["CommandResult", "CriterionRuntime", "SandboxResult"] - - -class SandboxResult(BaseModel): - """Result from sandbox code execution.""" - - stdout: list[str] = Field( - default_factory=list, - description="Captured stdout lines from the sandbox process.", - ) - stderr: list[str] = Field( - default_factory=list, - description="Captured stderr lines from the sandbox process.", - ) - - -class CommandResult(BaseModel): - """Result from command execution in a sandbox.""" - - stdout: str | None = Field( - default=None, - description="Captured stdout; ``None`` if the command never produced any.", - ) - stderr: str | None = Field( - default=None, - description="Captured stderr; ``None`` if the command never produced any.", - ) - exit_code: int | None = Field( - default=None, - description="Process exit code; ``None`` if the command could not be started.", - ) - - -class CriterionRuntime(Protocol): - """Execution surface injected into a ``Criterion`` at evaluation time. - - The runtime owns the sandbox lifecycle (create / reset timeout / - cleanup) on behalf of the criterion and exposes a small set of - primitives the criterion calls to gather evidence. A criterion that - doesn't need sandbox access or a judge simply ignores it. - - Surface-area constraint: this Protocol is narrowly scoped to sandbox - lifecycle, resource I/O, and event emission. It should not grow into - a generic service locator. - """ - - # ── sandbox lifecycle ───────────────────────────────────────────── - async def ensure_sandbox(self) -> None: ... - async def upload_files(self, files: list[dict]) -> None: ... - async def write_file(self, path: str, content: bytes) -> None: ... - async def run_command(self, command: str, timeout: int = 30) -> CommandResult: ... - async def execute_code(self, code: str) -> SandboxResult: ... - async def cleanup(self) -> None: ... - - # ── resource I/O ────────────────────────────────────────────────── - async def read_resource(self, name: str) -> bytes: ... - async def list_resources(self) -> "list[RunResourceView]": ... - async def get_all_files_for_task(self) -> "dict[str, bytes]": - """Return ``{name: bytes}`` for every resource produced by this task. - - Scoped to the ``(run_id, task_id)`` the runtime was constructed - with. On duplicate ``name`` s (same file published multiple - times) the newest ``created_at`` wins. Not size-capped — callers - expecting large resources should use ``list_resources()`` + - ``read_resource()`` instead. - """ - ... - - # ── DB access ───────────────────────────────────────────────────── - def db_read_session(self) -> "Session": ... - - # ── event emission ──────────────────────────────────────────────── - def event_sink(self) -> "SandboxEventSink": ... diff --git a/ergon_core/ergon_core/api/evaluation_context.py b/ergon_core/ergon_core/api/evaluation_context.py index b1bad4c6..2df27cf1 100644 --- a/ergon_core/ergon_core/api/evaluation_context.py +++ b/ergon_core/ergon_core/api/evaluation_context.py @@ -3,9 +3,9 @@ from typing import Annotated, Any from uuid import UUID -from ergon_core.api.criterion_runtime import CriterionRuntime from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask +from ergon_core.core.runtime.evaluation.protocols import CriterionRuntime from pydantic import BaseModel, ConfigDict, Field, SkipValidation diff --git a/ergon_core/ergon_core/api/evaluator.py b/ergon_core/ergon_core/api/evaluator.py index 8f51e62f..122a2448 100644 --- a/ergon_core/ergon_core/api/evaluator.py +++ b/ergon_core/ergon_core/api/evaluator.py @@ -5,10 +5,10 @@ from typing import Any, ClassVar from ergon_core.api.criterion import Criterion -from ergon_core.api.dependencies import check_packages from ergon_core.api.errors import DependencyError from ergon_core.api.results import CriterionResult, TaskEvaluationResult from ergon_core.api.task_types import BenchmarkTask +from ergon_core.core.runtime.dependencies import check_packages class Evaluator(ABC): diff --git a/ergon_core/ergon_core/api/types.py b/ergon_core/ergon_core/api/types.py deleted file mode 100644 index 6022a583..00000000 --- a/ergon_core/ergon_core/api/types.py +++ /dev/null @@ -1,16 +0,0 @@ -# ergon_core/ergon_core/api/types.py -"""Shared type aliases for the public API surface.""" - -from typing import Any - -type Tool = Any # slopcop: ignore[no-typing-any] -"""Framework-agnostic tool carrier. - -Intentionally unconstrained so workers can integrate with any agent -framework. ``ReActWorker`` passes these through to pydantic-ai's -``Agent(tools=...)``; nothing in our code enforces a structural protocol. -If we ever pin to pydantic-ai, tighten this to -``pydantic_ai.tools.Tool | Callable[..., Any]``. -""" - -__all__ = ["Tool"] diff --git a/ergon_core/ergon_core/api/worker.py b/ergon_core/ergon_core/api/worker.py index 1275def4..10ea1835 100644 --- a/ergon_core/ergon_core/api/worker.py +++ b/ergon_core/ergon_core/api/worker.py @@ -5,14 +5,14 @@ from typing import Any, ClassVar, Self from uuid import UUID -from ergon_core.api.dependencies import check_packages from ergon_core.api.errors import DependencyError -from ergon_core.api.generation import GenerationTurn from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext +from ergon_core.core.generation import GenerationTurn from ergon_core.core.persistence.context.repository import ContextEventRepository from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.runtime.dependencies import check_packages from sqlmodel import Session diff --git a/ergon_core/ergon_core/api/generation.py b/ergon_core/ergon_core/core/generation.py similarity index 54% rename from ergon_core/ergon_core/api/generation.py rename to ergon_core/ergon_core/core/generation.py index 449f24ed..af330b98 100644 --- a/ergon_core/ergon_core/api/generation.py +++ b/ergon_core/ergon_core/core/generation.py @@ -1,20 +1,13 @@ -"""Public output types for model generation. +"""Core model-generation turn types. -Workers yield GenerationTurn objects from their execute() generator. -The framework adapter (_build_turns in react_worker.py) populates all -typed list fields — workers never set messages_in, response_parts, or -tool_results directly. - -turn_token_ids and turn_logprobs are turn-level flat lists from vLLM's -choice.logprobs.content. Both are stored on the FIRST model-output context -event of each turn (group by turn_id to find them). Currently None until -the vLLM provider is updated to extract token IDs from provider_details. +These types are used by both public worker APIs and internal persistence. Keep +them in core so persistence can import them without loading ``ergon_core.api``. """ from datetime import datetime from typing import Annotated, Any, Literal -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from pydantic import BaseModel, Field @@ -28,11 +21,6 @@ class TokenLogprob(BaseModel): top_logprobs: list[JsonObject] = Field(default_factory=list) -# --------------------------------------------------------------------------- -# Request parts (ModelRequest input — what went INTO the model) -# --------------------------------------------------------------------------- - - class SystemPromptPart(BaseModel): model_config = {"frozen": True} part_kind: Literal["system-prompt"] = "system-prompt" @@ -59,11 +47,6 @@ class ToolReturnPart(BaseModel): ] -# --------------------------------------------------------------------------- -# Response parts (ModelResponse output — what the model produced) -# --------------------------------------------------------------------------- - - class TextPart(BaseModel): model_config = {"frozen": True} part_kind: Literal["text"] = "text" @@ -90,30 +73,16 @@ class ThinkingPart(BaseModel): ] -# --------------------------------------------------------------------------- -# GenerationTurn -# --------------------------------------------------------------------------- - - class GenerationTurn(BaseModel): - """One model generation turn within a worker episode. - - Populated by the framework adapter (_build_turns in react_worker.py). - Workers do not set any fields directly — they only yield the object. - """ + """One model generation turn within a worker episode.""" model_config = {"frozen": True} messages_in: list[ModelRequestPart] = Field(default_factory=list) response_parts: list[ModelResponsePart] = Field(default_factory=list) tool_results: list[ToolReturnPart] = Field(default_factory=list) - - # turn_token_ids and turn_logprobs: turn-level flat lists from vLLM. - # Stored on the FIRST model-output context event only; group by turn_id. - # None until vLLM provider exposes token IDs (logprobs arrive first). turn_token_ids: list[int] | None = None turn_logprobs: list[TokenLogprob] | None = None - policy_version: str | None = None started_at: datetime | None = None completed_at: datetime | None = None diff --git a/ergon_core/ergon_core/api/json_types.py b/ergon_core/ergon_core/core/json_types.py similarity index 78% rename from ergon_core/ergon_core/api/json_types.py rename to ergon_core/ergon_core/core/json_types.py index bdabefce..b9039cef 100644 --- a/ergon_core/ergon_core/api/json_types.py +++ b/ergon_core/ergon_core/core/json_types.py @@ -1,4 +1,4 @@ -"""JSON-compatible public type aliases.""" +"""JSON-compatible core type aliases.""" type JsonScalar = str | int | float | bool | None type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue] diff --git a/ergon_core/ergon_core/core/persistence/shared/enums.py b/ergon_core/ergon_core/core/persistence/shared/enums.py index b1c69268..33c79e74 100644 --- a/ergon_core/ergon_core/core/persistence/shared/enums.py +++ b/ergon_core/ergon_core/core/persistence/shared/enums.py @@ -33,3 +33,34 @@ class TrainingStatus(StrEnum): RUNNING = "running" COMPLETED = "completed" FAILED = "failed" + + +class RunResourceKind(StrEnum): + """Canonical kinds for ``run_resources.kind``. + + Stored as VARCHAR; enforced at the model/API boundary, not in the DB + schema. Each kind documents the publisher that produces it so a new + reader can trace a row back to the code that wrote it. + """ + + OUTPUT = "output" + """Explicit text artifact published by a worker/toolkit. + + Worker final assistant messages belong on + ``RunTaskExecution.final_assistant_message`` instead of this resource log. + """ + + REPORT = "report" + """Terminal report written by a worker into a sandbox publish directory.""" + + ARTIFACT = "artifact" + """Intermediate file a worker saved into a publish directory.""" + + SEARCH_CACHE = "search_cache" + """Raw JSON search payload cached by a search toolkit.""" + + NOTE = "note" + """Free-form scratch note written by an agent.""" + + IMPORT = "import" + """Copied snapshot materialized from another ``RunResource``.""" diff --git a/ergon_core/ergon_core/core/persistence/telemetry/models.py b/ergon_core/ergon_core/core/persistence/telemetry/models.py index 89062405..fefa8f4c 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/models.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/models.py @@ -9,15 +9,16 @@ from typing import TYPE_CHECKING from uuid import UUID, uuid4 -from ergon_core.api.json_types import JsonObject +import sqlalchemy as sa +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.shared.enums import ( + RunResourceKind as _RunResourceKind, RunStatus, TaskExecutionStatus, TrainingStatus, ) from ergon_core.core.utils import utcnow as _utcnow from pydantic import model_validator -import sqlalchemy as sa from sqlalchemy import JSON, Column, DateTime from sqlmodel import Field, SQLModel @@ -175,49 +176,6 @@ def _validate_fields(self) -> "RunTaskExecution": # --------------------------------------------------------------------------- -class RunResourceKind(StrEnum): - """Canonical kinds for ``run_resources.kind``. - - Stored as VARCHAR; enforced at the API boundary, not in the DB schema. - Each kind documents the publisher that produces it so a new reader can - trace a row back to the code that wrote it. - """ - - OUTPUT = "output" - """Explicit text artifact published by a worker/toolkit. - - Worker final assistant messages belong on - ``RunTaskExecution.final_assistant_message`` instead of this resource log. - """ - - REPORT = "report" - """Terminal report written by a worker into a sandbox publish - directory (default: ``/workspace/final_output/``). Produced by - ``SandboxResourcePublisher.sync()`` -- called from the - research-rubrics toolkit after every write and from - ``persist_outputs`` at task end.""" - - ARTIFACT = "artifact" - """Intermediate file a worker saved into a publish directory that - isn't a report (e.g. plots, derived datasets). Same publisher path - as ``REPORT`` but with a different ``PUBLISH_DIRS`` mapping.""" - - SEARCH_CACHE = "search_cache" - """Raw JSON search payload cached by the research toolkit's Exa - search handler. Produced by the toolkit calling - ``publisher.publish_value(kind=SEARCH_CACHE, ...)``.""" - - NOTE = "note" - """Free-form scratch note written by an agent via - ``publish_value(kind=NOTE, ...)`` -- used by the manager worker to - leave breadcrumbs for subsequent researchers.""" - - IMPORT = "import" - """Copied snapshot materialized from another ``RunResource`` into a task - workspace. The source resource remains immutable and owns its original - artifact; the import row belongs to the consuming task execution.""" - - class RunResource(SQLModel, table=True): __tablename__ = "run_resources" @@ -227,7 +185,10 @@ class RunResource(SQLModel, table=True): default=None, foreign_key="run_task_executions.id", ) - kind: str = "output" # Literal["output"] — str for SQLModel compat + kind: str = Field( + default="output", + description="RunResourceKind literal stored as a string for SQLModel compatibility.", + ) name: str mime_type: str file_path: str @@ -259,11 +220,11 @@ def _parse_metadata(cls, data: dict) -> JsonObject: def _validate_fields(self) -> "RunResource": self.__class__._parse_metadata(self.metadata_json) try: - RunResourceKind(self.kind) + _RunResourceKind(self.kind) except ValueError: raise ValueError( f"{self.kind!r} is not a valid RunResourceKind; " - f"valid values: {[e.value for e in RunResourceKind]}" + f"valid values: {[e.value for e in _RunResourceKind]}" ) return self diff --git a/ergon_core/ergon_core/core/providers/sandbox/resource_publisher.py b/ergon_core/ergon_core/core/providers/sandbox/resource_publisher.py index 5b9bc3d2..ea38fac7 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/resource_publisher.py +++ b/ergon_core/ergon_core/core/providers/sandbox/resource_publisher.py @@ -14,9 +14,9 @@ from uuid import UUID from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] -from ergon_core.api.run_resource import RunResourceView from ergon_core.core.persistence.queries import queries -from ergon_core.core.persistence.telemetry.models import RunResourceKind +from ergon_core.core.persistence.shared.enums import RunResourceKind +from ergon_core.core.runtime.resources import RunResourceView logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/api/dependencies.py b/ergon_core/ergon_core/core/runtime/dependencies.py similarity index 87% rename from ergon_core/ergon_core/api/dependencies.py rename to ergon_core/ergon_core/core/runtime/dependencies.py index bec71490..926678a8 100644 --- a/ergon_core/ergon_core/api/dependencies.py +++ b/ergon_core/ergon_core/core/runtime/dependencies.py @@ -9,7 +9,7 @@ def check_packages( ) -> list[str]: """Check that required packages are importable. - Returns a list of human-readable error strings. Empty list = all good. + Returns a list of human-readable error strings. Empty list = all good. """ errors: list[str] = [] for spec in required: diff --git a/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py b/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py index bb6c5fa3..545c0d9a 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py +++ b/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py @@ -1,9 +1,4 @@ -"""Default concrete implementation of ``CriterionRuntime``. - -The Protocol itself lives in ``ergon_core.api.criterion_runtime`` so that -``EvaluationContext`` (also in ``api/``) can type it without importing -from ``core``. This module is the real sandbox/resource implementation. -""" +"""Default concrete implementation of ``CriterionRuntime``.""" import logging from pathlib import Path @@ -11,12 +6,11 @@ from uuid import UUID from e2b import SandboxNotFoundException, TimeoutException -from ergon_core.api.criterion_runtime import ( +from ergon_core.core.runtime.evaluation.protocols import ( CommandResult, CriterionRuntime, SandboxResult, ) -from ergon_core.api.run_resource import RunResourceView from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource from ergon_core.core.providers.sandbox.errors import SandboxExpiredError @@ -25,6 +19,7 @@ SandboxEventSink, ) from ergon_core.core.runtime.evaluation.evaluation_schemas import CriterionContext +from ergon_core.core.runtime.resources import RunResourceView from pydantic import BaseModel, ConfigDict from sqlmodel import Session, desc, select @@ -264,14 +259,41 @@ async def read_resource(self, name: str) -> bytes: ) return result - async def list_resources(self) -> list[RunResourceView]: - """Return all ``RunResourceView`` DTOs for this run, newest first.""" + async def read_resource_by_id(self, resource_id: UUID) -> bytes: + """Read one worker-published blob by its RunResource primary key.""" with get_session() as session: - stmt = ( - select(RunResource) - .where(RunResource.run_id == self._run_id) - .order_by(desc(RunResource.created_at)) - ) + row = session.get(RunResource, resource_id) + + if row is None or row.run_id != self._run_id: + raise ResourceNotFoundError(f"No run_resource {resource_id!s} for run {self._run_id}") + + result = Path(row.file_path).read_bytes() + logger.info( + "criterion read_resource_by_id run_id=%s resource_id=%s size_bytes=%d", + self._run_id, + resource_id, + len(result), + ) + return result + + async def list_resources( + self, + task_execution_id: UUID | None = None, + ) -> list[RunResourceView]: + """Return resource DTOs for this run, newest first. + + Defaults to this runtime's evaluated task execution. Passing + ``task_execution_id`` lets a benchmark criterion inspect a related task + explicitly without core knowing benchmark semantics. + """ + effective_execution_id = ( + task_execution_id if task_execution_id is not None else self._task_id + ) + with get_session() as session: + stmt = select(RunResource).where(RunResource.run_id == self._run_id) + if effective_execution_id is not None: + stmt = stmt.where(RunResource.task_execution_id == effective_execution_id) + stmt = stmt.order_by(desc(RunResource.created_at)) rows = list(session.exec(stmt).all()) return [RunResourceView.from_row(r) for r in rows] diff --git a/ergon_core/ergon_core/core/runtime/evaluation/protocols.py b/ergon_core/ergon_core/core/runtime/evaluation/protocols.py new file mode 100644 index 00000000..59c74083 --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/evaluation/protocols.py @@ -0,0 +1,69 @@ +"""Criterion runtime contracts and small sandbox result DTOs.""" + +from typing import TYPE_CHECKING, Protocol +from uuid import UUID + +from pydantic import BaseModel, Field + +if TYPE_CHECKING: + from sqlmodel import Session + + from ergon_core.core.providers.sandbox.event_sink import SandboxEventSink + from ergon_core.core.runtime.resources import RunResourceView + +__all__ = ["CommandResult", "CriterionRuntime", "SandboxResult"] + + +class SandboxResult(BaseModel): + """Result from sandbox code execution.""" + + stdout: list[str] = Field( + default_factory=list, + description="Captured stdout lines from the sandbox process.", + ) + stderr: list[str] = Field( + default_factory=list, + description="Captured stderr lines from the sandbox process.", + ) + + +class CommandResult(BaseModel): + """Result from command execution in a sandbox.""" + + stdout: str | None = Field( + default=None, + description="Captured stdout; ``None`` if the command never produced any.", + ) + stderr: str | None = Field( + default=None, + description="Captured stderr; ``None`` if the command never produced any.", + ) + exit_code: int | None = Field( + default=None, + description="Process exit code; ``None`` if the command could not be started.", + ) + + +class CriterionRuntime(Protocol): + """Execution surface injected into a ``Criterion`` at evaluation time.""" + + async def ensure_sandbox(self) -> None: ... + async def upload_files(self, files: list[dict]) -> None: ... + async def write_file(self, path: str, content: bytes) -> None: ... + async def run_command(self, command: str, timeout: int = 30) -> CommandResult: ... + async def execute_code(self, code: str) -> SandboxResult: ... + async def cleanup(self) -> None: ... + + async def read_resource(self, name: str) -> bytes: ... + async def read_resource_by_id(self, resource_id: UUID) -> bytes: ... + async def list_resources( + self, + task_execution_id: UUID | None = None, + ) -> "list[RunResourceView]": ... + + async def get_all_files_for_task(self) -> "dict[str, bytes]": + """Return ``{name: bytes}`` for every resource produced by this task.""" + ... + + def db_read_session(self) -> "Session": ... + def event_sink(self) -> "SandboxEventSink": ... diff --git a/ergon_core/ergon_core/api/run_resource.py b/ergon_core/ergon_core/core/runtime/resources.py similarity index 85% rename from ergon_core/ergon_core/api/run_resource.py rename to ergon_core/ergon_core/core/runtime/resources.py index 8cc6c31b..bbb2f795 100644 --- a/ergon_core/ergon_core/api/run_resource.py +++ b/ergon_core/ergon_core/core/runtime/resources.py @@ -1,17 +1,11 @@ -"""Public read-only DTO for a ``run_resources`` row. - -The ORM row lives at ``ergon_core.core.persistence.telemetry.models.RunResource``; -this module is the API-layer shape callers should depend on. ``RunResourceKind`` -is imported at the package level (``ergon_core.api``), so prefer that import -site over reaching into the ORM module. -""" +"""Runtime resource DTOs and resource-log enums.""" from datetime import datetime from typing import TYPE_CHECKING from uuid import UUID -from ergon_core.api.json_types import JsonObject -from ergon_core.core.persistence.telemetry.models import RunResourceKind +from ergon_core.core.json_types import JsonObject +from ergon_core.core.persistence.shared.enums import RunResourceKind from pydantic import BaseModel, ConfigDict, Field if TYPE_CHECKING: diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_service.py b/ergon_core/ergon_core/core/runtime/services/workflow_service.py index 1375697c..11fa2a0c 100644 --- a/ergon_core/ergon_core/core/runtime/services/workflow_service.py +++ b/ergon_core/ergon_core/core/runtime/services/workflow_service.py @@ -4,19 +4,23 @@ from uuid import UUID from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode +from ergon_core.core.persistence.shared.enums import RunResourceKind, TaskExecutionStatus from ergon_core.core.persistence.shared.types import ( AssignedWorkerSlug, NodeId, RunId, TaskSlug, ) -from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import ( RunResource, - RunResourceKind, RunTaskExecution, ) from ergon_core.core.providers.sandbox.manager import BaseSandboxManager, DefaultSandboxManager +from ergon_core.core.runtime.services.task_management_dto import ( + AddSubtaskCommand, + AddSubtaskResult, +) +from ergon_core.core.runtime.services.task_management_service import TaskManagementService from ergon_core.core.runtime.services.workflow_dto import ( WorkflowBlockerRef, WorkflowDependencyRef, @@ -25,11 +29,6 @@ WorkflowResourceRef, WorkflowTaskRef, ) -from ergon_core.core.runtime.services.task_management_dto import ( - AddSubtaskCommand, - AddSubtaskResult, -) -from ergon_core.core.runtime.services.task_management_service import TaskManagementService from sqlmodel import Session, col, select ResourceScope = Literal["input", "upstream", "own", "children", "descendants", "visible"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py index 4fe22e60..cd653656 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py @@ -25,22 +25,21 @@ from uuid import UUID from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.api.generation import GenerationTurn, TextPart +from ergon_core.core.generation import GenerationTurn, TextPart from ergon_core.api.results import WorkerOutput from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.providers.sandbox.instrumentation import InstrumentedSandbox -from ergon_core.core.settings import settings from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest from ergon_core.core.runtime.services.communication_service import ( communication_service, ) - +from ergon_core.core.settings import settings +from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import ( SmokeSubworker, SubworkerResult, ) -from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager class BaseSmokeLeafWorker(Worker): diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py index 984f1bf6..a6d664d2 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py @@ -12,7 +12,7 @@ from uuid import UUID from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.api.generation import GenerationTurn, TextPart +from ergon_core.core.generation import GenerationTurn, TextPart from ergon_core.api.results import WorkerOutput from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES @@ -144,9 +144,7 @@ async def _send_recursive_completion_message(self, context: WorkerContext) -> No from_agent_id=f"leaf-{task_slug}", to_agent_id="parent", thread_topic="smoke-completion", - content=( - f"{task_slug}: recursive done nested={sorted(self._last_child_statuses)}" - ), + content=(f"{task_slug}: recursive done nested={sorted(self._last_child_statuses)}"), ), ) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py index ecdc78fe..e9f97203 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py @@ -18,7 +18,7 @@ from typing import ClassVar, final from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.api.generation import GenerationTurn, TextPart +from ergon_core.core.generation import GenerationTurn, TextPart from ergon_core.api.results import WorkerOutput from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES from ergon_core.core.persistence.shared.db import get_session @@ -38,7 +38,6 @@ from ergon_core.core.runtime.services.task_management_service import ( TaskManagementService, ) - from ergon_core.test_support.smoke_fixtures.smoke_base.constants import SUBTASK_GRAPH _CHILD_WAIT_TERMINAL_STATUSES = TERMINAL_STATUSES | {"blocked"} diff --git a/tests/integration/swebench_verified/test_criterion.py b/tests/integration/swebench_verified/test_criterion.py index 934f3730..c8ca2ccf 100644 --- a/tests/integration/swebench_verified/test_criterion.py +++ b/tests/integration/swebench_verified/test_criterion.py @@ -4,16 +4,14 @@ from uuid import uuid4 import pytest - -from ergon_core.api.criterion_runtime import CommandResult -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import WorkerOutput -from ergon_core.api.task_types import BenchmarkTask - from ergon_builtins.benchmarks.swebench_verified.criterion import ( SWEBenchTestCriterion, ) from ergon_builtins.benchmarks.swebench_verified.task_schemas import SWEBenchTaskPayload +from ergon_core.api.evaluation_context import EvaluationContext +from ergon_core.api.results import WorkerOutput +from ergon_core.api.task_types import BenchmarkTask +from ergon_core.core.runtime.evaluation.protocols import CommandResult def _task() -> BenchmarkTask[SWEBenchTaskPayload]: diff --git a/tests/unit/api/test_public_api_imports.py b/tests/unit/api/test_public_api_imports.py new file mode 100644 index 00000000..74970316 --- /dev/null +++ b/tests/unit/api/test_public_api_imports.py @@ -0,0 +1,36 @@ +import importlib +import subprocess +import sys + + +def test_telemetry_models_can_import_before_public_api() -> None: + telemetry = importlib.import_module("ergon_core.core.persistence.telemetry.models") + shared_enums = importlib.import_module("ergon_core.core.persistence.shared.enums") + public_api = importlib.import_module("ergon_core.api") + + assert shared_enums.RunResourceKind.REPORT.value == "report" + assert not hasattr(telemetry, "RunResourceKind") + assert not hasattr(public_api, "RunResourceKind") + + +def test_public_api_root_stays_authoring_scoped() -> None: + public_api = importlib.import_module("ergon_core.api") + + assert "__getattr__" not in public_api.__dict__ + assert not hasattr(public_api, "RunResourceView") + assert not hasattr(public_api, "CriterionRuntime") + assert not hasattr(public_api, "CommandResult") + assert not hasattr(public_api, "SandboxResult") + assert not hasattr(public_api, "Tool") + + +def test_core_api_app_imports_without_context_payload_cycle() -> None: + proc = subprocess.run( + [sys.executable, "-c", "import ergon_core.core.api.app; print('ok')"], + capture_output=True, + text=True, + check=False, + ) + + assert proc.returncode == 0, proc.stderr + assert "ok" in proc.stdout diff --git a/tests/unit/architecture/test_public_api_boundaries.py b/tests/unit/architecture/test_public_api_boundaries.py new file mode 100644 index 00000000..610cd348 --- /dev/null +++ b/tests/unit/architecture/test_public_api_boundaries.py @@ -0,0 +1,32 @@ +"""Architecture guards for the student-facing public API boundary.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] + +FORBIDDEN_IMPORT_SNIPPETS = ( + "from ergon_core.api.generation import", + "from ergon_core.api.json_types import", + "from ergon_core.api.run_resource import", + "from ergon_core.api.criterion_runtime import", + "from ergon_core.api.dependencies import", + "from ergon_core.api.types import", +) + +CHECKED_ROOTS = ( + ROOT / "ergon_builtins", + ROOT / "ergon_cli", + ROOT / "ergon_core" / "ergon_core" / "core", +) + + +def test_runtime_and_builtin_code_do_not_import_core_types_through_public_api() -> None: + offenders: list[str] = [] + for root in CHECKED_ROOTS: + for path in root.rglob("*.py"): + text = path.read_text() + for snippet in FORBIDDEN_IMPORT_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} imports via {snippet!r}") + + assert offenders == [] diff --git a/tests/unit/benchmarks/test_minif2f_proof_verification.py b/tests/unit/benchmarks/test_minif2f_proof_verification.py index c0d7c378..cd6201a4 100644 --- a/tests/unit/benchmarks/test_minif2f_proof_verification.py +++ b/tests/unit/benchmarks/test_minif2f_proof_verification.py @@ -9,14 +9,13 @@ from uuid import uuid4 import pytest - from ergon_builtins.benchmarks.minif2f.rules.proof_verification import ( ProofVerificationCriterion, ) from ergon_core.api import WorkerOutput -from ergon_core.api.criterion_runtime import CommandResult from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload +from ergon_core.core.runtime.evaluation.protocols import CommandResult from ergon_core.core.runtime.evaluation.criterion_runtime import ( ResourceNotFoundError, ) diff --git a/tests/unit/benchmarks/test_swebench_criterion_patch_source.py b/tests/unit/benchmarks/test_swebench_criterion_patch_source.py index 5234488d..ccb510e4 100644 --- a/tests/unit/benchmarks/test_swebench_criterion_patch_source.py +++ b/tests/unit/benchmarks/test_swebench_criterion_patch_source.py @@ -10,13 +10,12 @@ from uuid import uuid4 import pytest - from ergon_builtins.benchmarks.swebench_verified.criterion import SWEBenchTestCriterion from ergon_builtins.benchmarks.swebench_verified.task_schemas import SWEBenchTaskPayload from ergon_core.api import WorkerOutput -from ergon_core.api.criterion_runtime import CommandResult from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.api.task_types import BenchmarkTask +from ergon_core.core.runtime.evaluation.protocols import CommandResult def _fake_run(cmd: str, timeout: int = 30) -> CommandResult: diff --git a/tests/unit/builtins/common/test_transcript_adapters.py b/tests/unit/builtins/common/test_transcript_adapters.py index 1709193f..6d98b8a2 100644 --- a/tests/unit/builtins/common/test_transcript_adapters.py +++ b/tests/unit/builtins/common/test_transcript_adapters.py @@ -3,8 +3,9 @@ from ergon_builtins.common.llm_context.adapters.base import TranscriptAdapter from ergon_builtins.common.llm_context.adapters.pydantic_ai import ( PydanticAITranscriptAdapter, + TranscriptTurnCursor, ) -from ergon_core.api.generation import ( +from ergon_core.core.generation import ( GenerationTurn, TextPart as ErgonTextPart, ThinkingPart as ErgonThinkingPart, @@ -57,7 +58,9 @@ def _make_event(event_type: str, payload, sequence: int) -> RunContextEvent: def test_text_and_thinking_are_response_parts() -> None: - adapter: TranscriptAdapter[list[ModelRequest | ModelResponse], list[ModelRequest | ModelResponse]] + adapter: TranscriptAdapter[ + list[ModelRequest | ModelResponse], list[ModelRequest | ModelResponse] + ] adapter = PydanticAITranscriptAdapter() turns = adapter.build_turns( @@ -119,6 +122,61 @@ def test_tool_return_is_attached_to_generating_turn() -> None: assert result.content == '{"result": "found"}' +def test_incremental_extraction_does_not_emit_pending_tool_call_response() -> None: + adapter = PydanticAITranscriptAdapter() + cursor = TranscriptTurnCursor() + transcript = [ + ModelRequest(parts=[UserPromptPart(content="search")]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name="search", + tool_call_id="call-1", + args={"query": "ergon"}, + ) + ] + ), + ] + + assert adapter.build_new_turns(transcript, cursor, flush_pending=False) == [] + + flushed = adapter.build_new_turns(transcript, cursor, flush_pending=True) + assert len(flushed) == 1 + assert any(isinstance(part, ErgonToolCallPart) for part in flushed[0].response_parts) + + +def test_incremental_extraction_tracks_emitted_turns() -> None: + adapter = PydanticAITranscriptAdapter() + cursor = TranscriptTurnCursor() + transcript = [ + ModelRequest(parts=[UserPromptPart(content="search")]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name="search", + tool_call_id="call-1", + args={"query": "ergon"}, + ) + ] + ), + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="search", + tool_call_id="call-1", + content={"result": "found"}, + ) + ] + ), + ] + + first = adapter.build_new_turns(transcript, cursor, flush_pending=False) + second = adapter.build_new_turns(transcript, cursor, flush_pending=False) + + assert len(first) == 1 + assert second == [] + + def test_assemble_replay_reconstructs_pydantic_ai_messages() -> None: events = [ _make_event("system_prompt", SystemPromptPayload(text="sys"), 0), diff --git a/tests/unit/runtime/test_workflow_service.py b/tests/unit/runtime/test_workflow_service.py index 69e5a73a..d11ebb16 100644 --- a/tests/unit/runtime/test_workflow_service.py +++ b/tests/unit/runtime/test_workflow_service.py @@ -4,11 +4,14 @@ import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode -from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus +from ergon_core.core.persistence.shared.enums import ( + RunResourceKind, + RunStatus, + TaskExecutionStatus, +) from ergon_core.core.persistence.telemetry.models import ( RunRecord, RunResource, - RunResourceKind, RunTaskExecution, ) from ergon_core.core.runtime.services.workflow_service import WorkflowService diff --git a/tests/unit/state/test_criterion_runtime_di.py b/tests/unit/state/test_criterion_runtime_di.py index 6028d8c2..46b47b72 100644 --- a/tests/unit/state/test_criterion_runtime_di.py +++ b/tests/unit/state/test_criterion_runtime_di.py @@ -10,10 +10,8 @@ from uuid import uuid4 import pytest -from sqlmodel import Session - -from ergon_core.api.criterion_runtime import CriterionRuntime -from ergon_core.api.run_resource import RunResourceView +from ergon_core.core.runtime.evaluation.protocols import CriterionRuntime +from ergon_core.core.runtime.resources import RunResourceView from ergon_core.core.providers.sandbox.event_sink import ( DashboardEmitterSandboxEventSink, NoopSandboxEventSink, @@ -24,6 +22,7 @@ ResourceNotFoundError, ) from ergon_core.core.runtime.evaluation.evaluation_schemas import CriterionContext +from sqlmodel import Session def _criterion_context(run_id=None) -> CriterionContext: @@ -98,6 +97,58 @@ async def test_not_found_raises(self) -> None: with pytest.raises(ResourceNotFoundError, match="no_such_resource"): await runtime.read_resource("no_such_resource") + @pytest.mark.asyncio + async def test_read_resource_by_id_reads_exact_blob(self, tmp_path: Path) -> None: + """read_resource_by_id returns bytes from the exact resource row.""" + blob = tmp_path / "abc" + blob.write_bytes(b"exact-resource") + + run_id = uuid4() + resource_id = uuid4() + row = MagicMock() + row.id = resource_id + row.run_id = run_id + row.file_path = str(blob) + + runtime = _make_runtime(run_id=run_id) + + mock_session = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=False) + mock_session.get.return_value = row + + with patch( + "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + return_value=mock_session, + ): + result = await runtime.read_resource_by_id(resource_id) + + assert result == b"exact-resource" + + @pytest.mark.asyncio + async def test_read_resource_by_id_rejects_other_run(self, tmp_path: Path) -> None: + """read_resource_by_id does not expose resources from another run.""" + blob = tmp_path / "abc" + blob.write_bytes(b"wrong-run") + + row = MagicMock() + row.run_id = uuid4() + row.file_path = str(blob) + + runtime = _make_runtime(run_id=uuid4()) + + mock_session = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=False) + mock_session.get.return_value = row + + with patch( + "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + return_value=mock_session, + ): + with pytest.raises(ResourceNotFoundError, match="No run_resource"): + await runtime.read_resource_by_id(uuid4()) + class TestListResources: @pytest.mark.asyncio @@ -141,6 +192,51 @@ async def test_returns_empty_list_when_no_resources(self) -> None: assert result == [] + @pytest.mark.asyncio + async def test_defaults_to_runtime_task_execution(self) -> None: + """list_resources defaults to resources for the evaluated task execution.""" + task_execution_id = uuid4() + runtime = _make_runtime(task_id=task_execution_id) + mock_row = MagicMock() + + mock_session = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=False) + mock_session.exec.return_value.all.return_value = [mock_row] + + with ( + patch( + "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + return_value=mock_session, + ), + patch.object(RunResourceView, "from_row", return_value=MagicMock()) as mock_from_row, + ): + result = await runtime.list_resources() + + assert len(result) == 1 + mock_from_row.assert_called_once_with(mock_row) + mock_session.exec.assert_called_once() + + @pytest.mark.asyncio + async def test_accepts_explicit_task_execution_id(self) -> None: + """list_resources can inspect a related task execution explicitly.""" + runtime = _make_runtime(task_id=uuid4()) + related_execution_id = uuid4() + + mock_session = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=False) + mock_session.exec.return_value.all.return_value = [] + + with patch( + "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + return_value=mock_session, + ): + result = await runtime.list_resources(task_execution_id=related_execution_id) + + assert result == [] + mock_session.exec.assert_called_once() + class TestDbReadSession: def test_returns_session(self) -> None: @@ -215,6 +311,7 @@ def test_protocol_has_runtime_capability_methods_only(self) -> None: "execute_code", "cleanup", "read_resource", + "read_resource_by_id", "list_resources", "get_all_files_for_task", "db_read_session", diff --git a/tests/unit/state/test_generation_turn_build.py b/tests/unit/state/test_generation_turn_build.py index 6268f9a9..c7ea1f0a 100644 --- a/tests/unit/state/test_generation_turn_build.py +++ b/tests/unit/state/test_generation_turn_build.py @@ -2,22 +2,12 @@ """Tests for building GenerationTurn values from PydanticAI transcripts.""" from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter -from ergon_core.api.generation import ( +from ergon_core.core.generation import ( GenerationTurn, -) -from ergon_core.api.generation import ( SystemPromptPart as ErgonSystemPromptPart, -) -from ergon_core.api.generation import ( TextPart as ErgonTextPart, -) -from ergon_core.api.generation import ( ToolCallPart as ErgonToolCallPart, -) -from ergon_core.api.generation import ( ToolReturnPart as ErgonToolReturnPart, -) -from ergon_core.api.generation import ( UserPromptPart as ErgonUserPromptPart, ) from pydantic_ai.messages import ( diff --git a/tests/unit/test_swebench_criterion_no_sandbox.py b/tests/unit/test_swebench_criterion_no_sandbox.py index bc6e578a..9d12ef61 100644 --- a/tests/unit/test_swebench_criterion_no_sandbox.py +++ b/tests/unit/test_swebench_criterion_no_sandbox.py @@ -14,18 +14,17 @@ from __future__ import annotations -import ergon_builtins.benchmarks.swebench_verified.criterion as criterion_module from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 +import ergon_builtins.benchmarks.swebench_verified.criterion as criterion_module import pytest - from ergon_builtins.benchmarks.swebench_verified.criterion import SWEBenchTestCriterion from ergon_builtins.benchmarks.swebench_verified.task_schemas import SWEBenchTaskPayload -from ergon_core.api.criterion_runtime import CommandResult from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask +from ergon_core.core.runtime.evaluation.protocols import CommandResult def _task_payload() -> SWEBenchTaskPayload: From 494483266404d9e435aa607de703d83b9d4a1f3b Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:22:03 +0100 Subject: [PATCH 15/66] refactor: improve research rubric evaluation Keep rubric scoring and real-LLM artifact handling explicit so evaluation behavior is easier to inspect and test. Made-with: Cursor --- .../benchmarks/researchrubrics/benchmark.py | 1 - .../benchmarks/researchrubrics/criteria.py | 2 +- .../researchrubrics/judge_criterion.py | 13 +- .../benchmarks/researchrubrics/rubric.py | 2 + .../common/llm/structured_judge.py | 3 +- .../models/openrouter_backend.py | 3 +- .../models/openrouter_responses_backend.py | 33 ++++ .../ergon_builtins/observability/__init__.py | 1 + .../observability/pydantic_ai_logfire.py | 45 ++++++ .../tools/research_rubrics_toolkit.py | 89 ++++++----- .../workers/research_rubrics/_run_skill.py | 2 +- .../ergon_core/core/api/test_harness.py | 2 +- .../telemetry/evaluation_summary.py | 16 +- .../services/rubric_evaluation_service.py | 2 +- tests/e2e/test_researchrubrics_smoke.py | 2 +- .../researchrubrics/test_sandbox_manager.py | 1 - tests/real_llm/artifact_health.py | 91 ++++++++++- .../benchmarks/test_researchrubrics.py | 5 +- tests/real_llm/conftest.py | 8 +- tests/real_llm/test_artifact_health.py | 58 +++++++ .../test_evaluation_summary_contracts.py | 80 +++++++++- .../test_real_llm_rollout_artifact_health.py | 119 +++++++++++++- .../runtime/test_rubric_evaluation_service.py | 85 ++++++++++ .../state/test_research_rubrics_benchmark.py | 148 +++++++++++++++++- .../state/test_research_rubrics_workers.py | 28 ++-- 25 files changed, 760 insertions(+), 79 deletions(-) create mode 100644 ergon_builtins/ergon_builtins/models/openrouter_responses_backend.py create mode 100644 ergon_builtins/ergon_builtins/observability/__init__.py create mode 100644 ergon_builtins/ergon_builtins/observability/pydantic_ai_logfire.py create mode 100644 tests/real_llm/test_artifact_health.py create mode 100644 tests/unit/runtime/test_rubric_evaluation_service.py diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py index 974e2b0c..2ae03c96 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py @@ -8,7 +8,6 @@ from typing import Any, ClassVar from datasets import load_dataset - from ergon_core.api.benchmark import Benchmark from ergon_core.api.benchmark_deps import BenchmarkDeps from ergon_core.api.task_types import BenchmarkTask diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/criteria.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/criteria.py index e163caaf..56353b35 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/criteria.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/criteria.py @@ -22,7 +22,7 @@ def build_criteria_from_rubrics( """ return [ ResearchRubricsJudgeCriterion( - name=f"criterion_{idx}", + slug=f"criterion_{idx}", rubric=criterion, ) for idx, criterion in enumerate(rubric_criteria) diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py index ea2a6583..be8157bf 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py @@ -82,17 +82,22 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: verdict=verdict, evaluated_resource_ids=evaluated_resource_ids, final_outputs=final_outputs, + rubric=self.rubric, + model=self.model, ), ) + @classmethod def _build_observation( - self, + cls, *, system_prompt: str, user_prompt: str, verdict: ResearchRubricsVerdict, evaluated_resource_ids: list[str], final_outputs: list[_ResourceEvidence], + rubric: RubricCriterion, + model: str, ) -> CriterionObservation: return CriterionObservation( prompt_messages=[ @@ -101,10 +106,10 @@ def _build_observation( ], evidence_resource_ids=evaluated_resource_ids, output=verdict.model_dump(mode="json"), - model=self.model, + model=model, details={ - "axis": self.rubric.axis, - "rubric_weight": self.rubric.weight, + "axis": rubric.axis, + "rubric_weight": rubric.weight, "primary_evidence": ( f"run_resource:{final_outputs[0].resource.name}" if final_outputs diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py index fcc84ca4..7399edaa 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py @@ -95,6 +95,8 @@ def aggregate_task( criterion_results=results, metadata={ "total_score": total_score, + "score_scale": "normalized_0_1", + "raw_score": total_score, "max_possible": max_possible, "min_possible": min_possible, }, diff --git a/ergon_builtins/ergon_builtins/common/llm/structured_judge.py b/ergon_builtins/ergon_builtins/common/llm/structured_judge.py index b383841a..a621f920 100644 --- a/ergon_builtins/ergon_builtins/common/llm/structured_judge.py +++ b/ergon_builtins/ergon_builtins/common/llm/structured_judge.py @@ -3,10 +3,11 @@ from collections.abc import Sequence from typing import Literal, TypeVar, cast -from ergon_builtins.models.resolution import resolve_model_target from pydantic import BaseModel from pydantic_ai import Agent +from ergon_builtins.models.resolution import resolve_model_target + T = TypeVar("T", bound=BaseModel) diff --git a/ergon_builtins/ergon_builtins/models/openrouter_backend.py b/ergon_builtins/ergon_builtins/models/openrouter_backend.py index ac91390d..62d47044 100644 --- a/ergon_builtins/ergon_builtins/models/openrouter_backend.py +++ b/ergon_builtins/ergon_builtins/models/openrouter_backend.py @@ -2,10 +2,11 @@ import logging -from ergon_builtins.models.resolution import ResolvedModel from ergon_core.core.settings import settings from pydantic_ai.models.openrouter import OpenRouterModel, OpenRouterProvider +from ergon_builtins.models.resolution import ResolvedModel + logger = logging.getLogger(__name__) diff --git a/ergon_builtins/ergon_builtins/models/openrouter_responses_backend.py b/ergon_builtins/ergon_builtins/models/openrouter_responses_backend.py new file mode 100644 index 00000000..eff83523 --- /dev/null +++ b/ergon_builtins/ergon_builtins/models/openrouter_responses_backend.py @@ -0,0 +1,33 @@ +"""OpenAI Responses-compatible models routed through OpenRouter billing.""" + +import logging + +from ergon_core.core.settings import settings +from pydantic_ai.models.openai import OpenAIResponsesModel +from pydantic_ai.providers.openai import OpenAIProvider + +from ergon_builtins.models.resolution import ResolvedModel + +logger = logging.getLogger(__name__) + + +def resolve_openrouter_responses( + target: str, + *, + model_name: str | None = None, + policy_version: str | None = None, + api_key: str | None = None, +) -> ResolvedModel: + """Resolve ``openai-responses:model`` through OpenRouter's Responses endpoint.""" + resolved_name = model_name or _openrouter_model_name(target.removeprefix("openai-responses:")) + provider = OpenAIProvider( + base_url="https://openrouter.ai/api/v1", + api_key=api_key or settings.openrouter_api_key, + ) + model = OpenAIResponsesModel(model_name=resolved_name, provider=provider) + logger.info("Resolved OpenRouter Responses model: model_name=%s", resolved_name) + return ResolvedModel(model=model, policy_version=policy_version, supports_logprobs=False) + + +def _openrouter_model_name(name: str) -> str: + return name if "/" in name else f"openai/{name}" diff --git a/ergon_builtins/ergon_builtins/observability/__init__.py b/ergon_builtins/ergon_builtins/observability/__init__.py new file mode 100644 index 00000000..27eb1233 --- /dev/null +++ b/ergon_builtins/ergon_builtins/observability/__init__.py @@ -0,0 +1 @@ +"""Temporary observability hooks for built-in pydantic-ai workers.""" diff --git a/ergon_builtins/ergon_builtins/observability/pydantic_ai_logfire.py b/ergon_builtins/ergon_builtins/observability/pydantic_ai_logfire.py new file mode 100644 index 00000000..0f1a6c28 --- /dev/null +++ b/ergon_builtins/ergon_builtins/observability/pydantic_ai_logfire.py @@ -0,0 +1,45 @@ +"""Opt-in Logfire instrumentation for pydantic-ai based built-in workers.""" + +from __future__ import annotations + +import importlib +import logging +import os +from typing import Any + +logger = logging.getLogger(__name__) + +_CONFIGURED = False + + +def configure_pydantic_ai_logfire(*, logfire_module: Any | None = None) -> bool: + """Configure Logfire's pydantic-ai instrumentation once when explicitly enabled.""" + global _CONFIGURED + if os.environ.get("ERGON_LOGFIRE_PYDANTIC_AI") != "1": + return False + if _CONFIGURED: + return True + + if logfire_module is None: + logfire_module = importlib.import_module("logfire") + + kwargs = { + "send_to_logfire": "if-token-present", + "service_name": os.environ.get("ERGON_LOGFIRE_SERVICE_NAME", "ergon-builtins"), + "environment": os.environ.get("ERGON_LOGFIRE_ENVIRONMENT", "local"), + "config_dir": os.environ.get("ERGON_LOGFIRE_CONFIG_DIR"), + "console": False, + } + if kwargs["config_dir"] is None: + kwargs.pop("config_dir") + + logfire_module.configure(**kwargs) + logfire_module.instrument_pydantic_ai(include_content=True) + _CONFIGURED = True + logger.info("Enabled Logfire pydantic-ai instrumentation") + return True + + +def _reset_for_tests() -> None: + global _CONFIGURED + _CONFIGURED = False diff --git a/ergon_builtins/ergon_builtins/tools/research_rubrics_toolkit.py b/ergon_builtins/ergon_builtins/tools/research_rubrics_toolkit.py index 9ab1e8ef..7575594d 100644 --- a/ergon_builtins/ergon_builtins/tools/research_rubrics_toolkit.py +++ b/ergon_builtins/ergon_builtins/tools/research_rubrics_toolkit.py @@ -18,10 +18,8 @@ from collections.abc import Awaitable, Callable from typing import cast -try: - from pydantic_ai.tools import Tool -except ImportError: # pragma: no cover -- defensive - Tool = None # type: ignore[misc,assignment] +from pydantic_ai import RunContext +from pydantic_ai.tools import Tool from ergon_builtins.benchmarks.researchrubrics.toolkit_types import ( DocumentResponse, @@ -30,6 +28,10 @@ ReportWriteResponse, SearchResponse, ) +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetExhaustedResult, +) from ergon_builtins.workers.research_rubrics._run_skill import ( ExaGetContentSkillRequest, ExaQASkillRequest, @@ -83,53 +85,60 @@ def build_tools( def _exa_search(self) -> "Tool": async def exa_search( + ctx: "RunContext[AgentToolBudgetDeps]", query: str, num_results: int = 5, - ) -> SearchResponse: + ) -> SearchResponse | AgentToolBudgetExhaustedResult: """Search the web via Exa. Returns up to ``num_results`` hits with text excerpts (up to ~25 000 chars each). An empty ``results`` list is legitimate and distinct from a transport failure. """ - return cast( - SearchResponse, - await self._run_skill( - ExaSearchSkillRequest(query=query, num_results=num_results), - ), + tool_budget = ctx.deps.tool_budget + if tool_budget.increment("exa_search", "other") > tool_budget.max_other_tool_calls: + return tool_budget.exhausted_result("non-workflow tool budget reached") + resp = cast( + SearchResponse | AgentToolBudgetExhaustedResult, + await self._run_skill(ExaSearchSkillRequest(query=query, num_results=num_results)), ) + return cast(SearchResponse, resp) - return Tool(function=exa_search, takes_ctx=False) + return Tool(function=exa_search, takes_ctx=True) def _exa_qa(self) -> "Tool": - async def exa_qa(question: str) -> QAResponse: + async def exa_qa( + ctx: "RunContext[AgentToolBudgetDeps]", + question: str, + ) -> QAResponse | AgentToolBudgetExhaustedResult: """Ask Exa a direct question and get a synthesised answer with source citations. """ - return cast( - QAResponse, - await self._run_skill( - ExaQASkillRequest(question=question), - ), - ) + tool_budget = ctx.deps.tool_budget + if tool_budget.increment("exa_qa", "other") > tool_budget.max_other_tool_calls: + return tool_budget.exhausted_result("non-workflow tool budget reached") + resp = cast(QAResponse, await self._run_skill(ExaQASkillRequest(question=question))) + return resp - return Tool(function=exa_qa, takes_ctx=False) + return Tool(function=exa_qa, takes_ctx=True) def _exa_get_content(self) -> "Tool": - async def exa_get_content(url: str) -> DocumentResponse: + async def exa_get_content( + ctx: "RunContext[AgentToolBudgetDeps]", + url: str, + ) -> DocumentResponse | AgentToolBudgetExhaustedResult: """Fetch and extract readable text from a URL via Exa. Returns the full document text, word count, and publication date when available. """ - return cast( - DocumentResponse, - await self._run_skill( - ExaGetContentSkillRequest(url=url), - ), - ) + tool_budget = ctx.deps.tool_budget + if tool_budget.increment("exa_get_content", "other") > tool_budget.max_other_tool_calls: + return tool_budget.exhausted_result("non-workflow tool budget reached") + resp = cast(DocumentResponse, await self._run_skill(ExaGetContentSkillRequest(url=url))) + return resp - return Tool(function=exa_get_content, takes_ctx=False) + return Tool(function=exa_get_content, takes_ctx=True) # ------------------------------------------------------------------ # Report drafting tools @@ -137,6 +146,7 @@ async def exa_get_content(url: str) -> DocumentResponse: def _write_report_draft(self) -> "Tool": async def write_report_draft( + ctx: "RunContext[AgentToolBudgetDeps]", relative_path: str, content: str, ) -> ReportWriteResponse: @@ -146,6 +156,7 @@ async def write_report_draft( ``run_resources`` log so the manager can observe it via the graph toolkit. Paths that escape ``/workspace/`` are rejected. """ + ctx.deps.tool_budget.increment("write_report_draft", "finalization") resp = cast( ReportWriteResponse, await self._run_skill( @@ -156,10 +167,11 @@ async def write_report_draft( await self._publisher_sync() return resp - return Tool(function=write_report_draft, takes_ctx=False) + return Tool(function=write_report_draft, takes_ctx=True) def _edit_report_draft(self) -> "Tool": async def edit_report_draft( + ctx: "RunContext[AgentToolBudgetDeps]", relative_path: str, patch: str, ) -> ReportWriteResponse: @@ -170,6 +182,7 @@ async def edit_report_draft( the ``run_resources`` log. Paths that escape ``/workspace/`` are rejected. """ + ctx.deps.tool_budget.increment("edit_report_draft", "finalization") resp = cast( ReportWriteResponse, await self._run_skill( @@ -180,21 +193,27 @@ async def edit_report_draft( await self._publisher_sync() return resp - return Tool(function=edit_report_draft, takes_ctx=False) + return Tool(function=edit_report_draft, takes_ctx=True) def _read_report_draft(self) -> "Tool": async def read_report_draft( + ctx: "RunContext[AgentToolBudgetDeps]", relative_path: str, - ) -> ReportReadResponse: + ) -> ReportReadResponse | AgentToolBudgetExhaustedResult: """Read a draft from ``/workspace/``. Read-only -- does not trigger a publish. """ - return cast( + tool_budget = ctx.deps.tool_budget + if ( + tool_budget.increment("read_report_draft", "other") + > tool_budget.max_other_tool_calls + ): + return tool_budget.exhausted_result("non-workflow tool budget reached") + resp = cast( ReportReadResponse, - await self._run_skill( - ReportReadSkillRequest(relative_path=relative_path), - ), + await self._run_skill(ReportReadSkillRequest(relative_path=relative_path)), ) + return resp - return Tool(function=read_report_draft, takes_ctx=False) + return Tool(function=read_report_draft, takes_ctx=True) diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/_run_skill.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/_run_skill.py index 96a55aee..19359993 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/_run_skill.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/_run_skill.py @@ -25,7 +25,6 @@ from types import UnionType from typing import ClassVar, Literal, Protocol, cast, get_args, get_type_hints -from ergon_builtins.models.resolution import resolve_model_target from pydantic import BaseModel from pydantic_ai import Agent @@ -36,6 +35,7 @@ ReportWriteResponse, SearchResponse, ) +from ergon_builtins.models.resolution import resolve_model_target logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/api/test_harness.py b/ergon_core/ergon_core/core/api/test_harness.py index c51f7f57..117e0836 100644 --- a/ergon_core/ergon_core/core/api/test_harness.py +++ b/ergon_core/ergon_core/core/api/test_harness.py @@ -21,10 +21,10 @@ import inngest from ergon_cli.composition import build_experiment +from ergon_core.core.persistence.context.models import RunContextEvent from ergon_core.core.persistence.graph.models import RunGraphMutation, RunGraphNode from ergon_core.core.persistence.shared.db import get_engine from ergon_core.core.persistence.shared.enums import RunStatus -from ergon_core.core.persistence.context.models import RunContextEvent from ergon_core.core.persistence.telemetry.models import ( ExperimentCohort, RunRecord, diff --git a/ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py b/ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py index c3500a1a..b58f4ea3 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py @@ -7,7 +7,9 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator + +from ergon_core.api.results import CriterionObservation EvalCriterionStatus = Literal["passed", "failed", "errored", "skipped"] @@ -15,6 +17,7 @@ class CriterionResultEntry(BaseModel): """One criterion result as stored in the evaluation summary.""" + criterion_slug: str criterion_name: str criterion_type: str stage_num: int = 0 @@ -33,8 +36,18 @@ class CriterionResultEntry(BaseModel): evaluation_input: str | None = None evaluated_action_ids: list[str] = Field(default_factory=list) evaluated_resource_ids: list[str] = Field(default_factory=list) + observation: CriterionObservation | None = None error: dict | None = None + @model_validator(mode="before") + @classmethod + def _populate_criterion_slug(cls, data): + if isinstance(data, dict) and "criterion_slug" not in data: + name = data.get("criterion_name") + if isinstance(name, str): + data["criterion_slug"] = name + return data + class EvaluationSummary(BaseModel): """Typed schema for RunTaskEvaluation.summary_json.""" @@ -45,4 +58,5 @@ class EvaluationSummary(BaseModel): stages_evaluated: int = 0 stages_passed: int = 0 failed_gate: str | None = None + metadata: dict = Field(default_factory=dict) criterion_results: list[CriterionResultEntry] = Field(default_factory=list) diff --git a/ergon_core/ergon_core/core/runtime/services/rubric_evaluation_service.py b/ergon_core/ergon_core/core/runtime/services/rubric_evaluation_service.py index eaa2aa0f..0c5b8292 100644 --- a/ergon_core/ergon_core/core/runtime/services/rubric_evaluation_service.py +++ b/ergon_core/ergon_core/core/runtime/services/rubric_evaluation_service.py @@ -44,7 +44,7 @@ async def evaluate( CriterionSpec( criterion=c, criterion_idx=i, - max_score=c.weight, + max_score=c.score_spec.max_score, stage_idx=0, stage_name="default", aggregation_weight=c.weight, diff --git a/tests/e2e/test_researchrubrics_smoke.py b/tests/e2e/test_researchrubrics_smoke.py index af13df11..ca532e18 100644 --- a/tests/e2e/test_researchrubrics_smoke.py +++ b/tests/e2e/test_researchrubrics_smoke.py @@ -36,8 +36,8 @@ _assert_sadpath_thread_messages, _assert_sandbox_command_wal, _assert_sandbox_lifecycle_events, - _assert_thread_messages_ordered, _assert_temporal_ordering, + _assert_thread_messages_ordered, wait_for_terminal_status, ) from tests.e2e._submit import submit_cohort diff --git a/tests/integration/researchrubrics/test_sandbox_manager.py b/tests/integration/researchrubrics/test_sandbox_manager.py index 32746be1..7351eb28 100644 --- a/tests/integration/researchrubrics/test_sandbox_manager.py +++ b/tests/integration/researchrubrics/test_sandbox_manager.py @@ -16,7 +16,6 @@ from uuid import uuid4 import pytest - from ergon_core.core.providers.sandbox.manager import BaseSandboxManager from ergon_core.core.providers.sandbox.research_rubrics_manager import ( ResearchRubricsSandboxManager, diff --git a/tests/real_llm/artifact_health.py b/tests/real_llm/artifact_health.py index 24522ccf..623e7779 100644 --- a/tests/real_llm/artifact_health.py +++ b/tests/real_llm/artifact_health.py @@ -25,6 +25,10 @@ class ArtifactHealthSummary(BaseModel): resource_count: int graph_node_count: int criterion_count: int + workflow_tool_calls: int = 0 + other_tool_calls: int = 0 + budget_exhausted: bool = False + missing_final_report: bool = False normalized_scores: list[float] = Field(default_factory=list) worker_slugs: list[str] = Field(default_factory=list) issues: list[ArtifactHealthIssue] = Field(default_factory=list) @@ -55,7 +59,71 @@ def _criterion_has_reasoning(criterion: dict[str, Any]) -> bool: # slopcop: ign return bool(criterion.get("feedback") or criterion.get("model_reasoning")) -def analyze_rollout_artifacts( +def _payload(row: dict[str, Any]) -> dict[str, Any]: # slopcop: ignore[no-typing-any] + payload = row.get("payload") or {} + if isinstance(payload, str): + return json.loads(payload) + return payload if isinstance(payload, dict) else {} + + +def _contains_budget_exhaustion(value: Any) -> bool: # slopcop: ignore[no-typing-any] + if isinstance(value, dict): + if value.get("status") == "TOOL_BUDGET_EXHAUSTED": + return True + return any(_contains_budget_exhaustion(v) for v in value.values()) + if isinstance(value, list): + return any(_contains_budget_exhaustion(v) for v in value) + return False + + +def _tool_budget_signals( + context_events: list[dict[str, Any]], # slopcop: ignore[no-typing-any] +) -> tuple[int, int, bool]: + workflow_tool_calls = 0 + other_tool_calls = 0 + budget_exhausted = False + for event in context_events: + payload = _payload(event) + budget_exhausted = budget_exhausted or _contains_budget_exhaustion(payload) + if event.get("event_type") != "tool_call": + continue + tool_name = payload.get("tool_name") + if tool_name == "workflow": + workflow_tool_calls += 1 + elif tool_name: + other_tool_calls += 1 + return workflow_tool_calls, other_tool_calls, budget_exhausted + + +def _is_completed_execution(row: dict[str, Any]) -> bool: # slopcop: ignore[no-typing-any] + return row.get("status") == "completed" + + +def _is_report_resource(row: dict[str, Any]) -> bool: # slopcop: ignore[no-typing-any] + return row.get("kind") == "report" + + +def _missing_task_report( + executions: list[dict[str, Any]], # slopcop: ignore[no-typing-any] + resources: list[dict[str, Any]], # slopcop: ignore[no-typing-any] +) -> bool: + completed_execution_ids = { + str(row["id"]) + for row in executions + if row.get("id") is not None and _is_completed_execution(row) + } + if not completed_execution_ids: + return False + + report_execution_ids = { + str(resource["task_execution_id"]) + for resource in resources + if resource.get("task_execution_id") is not None and _is_report_resource(resource) + } + return not completed_execution_ids.issubset(report_execution_ids) + + +def analyze_rollout_artifacts( # noqa: C901 out_dir: Path, *, expected_task_count: int | None = None, @@ -69,6 +137,7 @@ def analyze_rollout_artifacts( evaluations = _read_jsonl(db_dir / "run_task_evaluations.jsonl") resources = _read_jsonl(db_dir / "run_resources.jsonl") graph_nodes = _read_jsonl(db_dir / "run_graph_nodes.jsonl") + context_events = _read_jsonl(db_dir / "run_context_events.jsonl") task_count = len(executions) evaluation_count = len(evaluations) @@ -81,6 +150,10 @@ def analyze_rollout_artifacts( if (slug := node.get("assigned_worker_slug") or node.get("assignedWorkerSlug")) } ) + workflow_tool_calls, other_tool_calls, budget_exhausted = _tool_budget_signals( + context_events, + ) + missing_final_report = _missing_task_report(executions, resources) issues: list[ArtifactHealthIssue] = [] if expected_task_count is not None and task_count != expected_task_count: @@ -90,10 +163,7 @@ def analyze_rollout_artifacts( message=f"Expected {expected_task_count} task executions, found {task_count}.", ) ) - if ( - expected_evaluation_count is not None - and evaluation_count < expected_evaluation_count - ): + if expected_evaluation_count is not None and evaluation_count < expected_evaluation_count: issues.append( ArtifactHealthIssue( code="missing_evaluations", @@ -110,6 +180,13 @@ def analyze_rollout_artifacts( message="No resources were dumped for a completed rollout.", ) ) + if missing_final_report: + issues.append( + ArtifactHealthIssue( + code="missing_final_report", + message="A completed task execution has no task-scoped report resource.", + ) + ) if expected_task_count is not None and graph_node_count < expected_task_count: issues.append( ArtifactHealthIssue( @@ -165,6 +242,10 @@ def analyze_rollout_artifacts( resource_count=resource_count, graph_node_count=graph_node_count, criterion_count=criterion_count, + workflow_tool_calls=workflow_tool_calls, + other_tool_calls=other_tool_calls, + budget_exhausted=budget_exhausted, + missing_final_report=missing_final_report, normalized_scores=normalized_scores, worker_slugs=worker_slugs, issues=issues, diff --git a/tests/real_llm/benchmarks/test_researchrubrics.py b/tests/real_llm/benchmarks/test_researchrubrics.py index f8a842b1..ee90c4be 100644 --- a/tests/real_llm/benchmarks/test_researchrubrics.py +++ b/tests/real_llm/benchmarks/test_researchrubrics.py @@ -30,8 +30,6 @@ from uuid import UUID import pytest -from sqlmodel import select - from ergon_core.core.persistence.shared.db import ensure_db, get_session from ergon_core.core.persistence.telemetry.models import ( RunRecord, @@ -40,7 +38,9 @@ ) from ergon_core.core.providers.generation.openrouter_budget import OpenRouterBudget from ergon_core.core.settings import settings +from sqlmodel import select +from tests.real_llm.rollout import _fingerprint as fingerprint from tests.real_llm.rollout import ( capture_dashboard, dump_rollout, @@ -48,7 +48,6 @@ write_manifest, write_report, ) -from tests.real_llm.rollout import _fingerprint as fingerprint pytestmark = [pytest.mark.real_llm, pytest.mark.asyncio] diff --git a/tests/real_llm/conftest.py b/tests/real_llm/conftest.py index b94f464d..6e855fb0 100644 --- a/tests/real_llm/conftest.py +++ b/tests/real_llm/conftest.py @@ -39,14 +39,14 @@ def _skip_if_not_enabled(real_llm_enabled: bool, request: pytest.FixtureRequest) # Re-export fixtures so pytest discovers them session-wide. -from tests.real_llm.fixtures.openrouter_budget import ( - _budget_gate, - openrouter_budget, -) from tests.real_llm.fixtures.harness_client import ( BackendHarnessClient, harness_client, ) +from tests.real_llm.fixtures.openrouter_budget import ( + _budget_gate, + openrouter_budget, +) from tests.real_llm.fixtures.playwright_client import ( playwright_browser, playwright_context, diff --git a/tests/real_llm/test_artifact_health.py b/tests/real_llm/test_artifact_health.py new file mode 100644 index 00000000..6a9a977c --- /dev/null +++ b/tests/real_llm/test_artifact_health.py @@ -0,0 +1,58 @@ +import importlib.util +import json +from pathlib import Path + +_ARTIFACT_HEALTH_PATH = Path(__file__).with_name("artifact_health.py") +_SPEC = importlib.util.spec_from_file_location("artifact_health", _ARTIFACT_HEALTH_PATH) +assert _SPEC is not None +assert _SPEC.loader is not None +artifact_health = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(artifact_health) +analyze_rollout_artifacts = artifact_health.analyze_rollout_artifacts + + +def _write_jsonl(path: Path, rows: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(row) for row in rows)) + + +def test_artifact_health_reports_tool_budget_signals(tmp_path: Path) -> None: + db_dir = tmp_path / "db" + db_dir.mkdir() + for name in [ + "run_task_executions.jsonl", + "run_task_evaluations.jsonl", + "run_resources.jsonl", + "run_graph_nodes.jsonl", + ]: + (db_dir / name).write_text("") + _write_jsonl( + db_dir / "run_context_events.jsonl", + [ + { + "event_type": "tool_call", + "payload": {"event_type": "tool_call", "tool_name": "workflow"}, + }, + { + "event_type": "tool_call", + "payload": {"event_type": "tool_call", "tool_name": "exa_search"}, + }, + { + "event_type": "tool_result", + "payload": { + "event_type": "tool_result", + "content": { + "status": "TOOL_BUDGET_EXHAUSTED", + "reason": "non-workflow tool budget reached", + }, + }, + }, + ], + ) + + health = analyze_rollout_artifacts(tmp_path) + + assert health.workflow_tool_calls == 1 + assert health.other_tool_calls == 1 + assert health.budget_exhausted is True + assert health.missing_final_report is True diff --git a/tests/unit/runtime/test_evaluation_summary_contracts.py b/tests/unit/runtime/test_evaluation_summary_contracts.py index 2aa2d5e4..a33bbfbf 100644 --- a/tests/unit/runtime/test_evaluation_summary_contracts.py +++ b/tests/unit/runtime/test_evaluation_summary_contracts.py @@ -7,7 +7,12 @@ import pytest from ergon_core.api.criterion import Criterion from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult, TaskEvaluationResult +from ergon_core.api.results import ( + CriterionObservation, + CriterionObservationMessage, + CriterionResult, + TaskEvaluationResult, +) from ergon_core.core.persistence.telemetry.evaluation_summary import CriterionResultEntry from ergon_core.core.runtime.evaluation.evaluation_schemas import CriterionSpec from ergon_core.core.runtime.services.evaluation_persistence_service import ( @@ -30,6 +35,7 @@ def _service_result( feedback: str | None, criterion_score: float = 1.0, criterion_weight: float = 1.0, + spec_max_score: float = 1.0, passed: bool = True, model_reasoning: str | None = None, skipped_reason: str | None = None, @@ -37,8 +43,14 @@ def _service_result( evaluated_action_ids: list[str] | None = None, evaluated_resource_ids: list[str] | None = None, criterion_evaluation_input: str | None = None, + criterion_description: str = "Criterion description", + criterion_observation: CriterionObservation | None = None, + task_metadata: dict | None = None, ) -> EvaluationServiceResult: - criterion = _Criterion(name="Criterion description") + criterion = _Criterion( + slug="criterion-slug", + description=criterion_description, + ) return EvaluationServiceResult( result=TaskEvaluationResult( task_slug="task", @@ -58,14 +70,16 @@ def _service_result( evaluated_action_ids=evaluated_action_ids or [], evaluated_resource_ids=evaluated_resource_ids or [], evaluation_input=criterion_evaluation_input, + observation=criterion_observation, ) ], + metadata=task_metadata or {}, ), specs=[ CriterionSpec( criterion=criterion, criterion_idx=0, - max_score=1.0, + max_score=spec_max_score, ) ], ) @@ -74,6 +88,7 @@ def _service_result( def test_criterion_result_entry_requires_criterion_description() -> None: with pytest.raises(ValidationError): CriterionResultEntry( + criterion_slug="criterion", criterion_name="criterion", criterion_type="test-criterion", score=1.0, @@ -131,6 +146,64 @@ def test_build_evaluation_summary_includes_required_criterion_status_fields() -> assert entry.skipped_reason is None +def test_build_evaluation_summary_preserves_evaluator_normalized_score() -> None: + summary = build_evaluation_summary( + _service_result( + feedback="criterion ran", + criterion_score=0.5, + criterion_weight=2.0, + spec_max_score=2.0, + passed=True, + task_metadata={"score_scale": "normalized_0_1"}, + ), + evaluation_input=None, + ) + + assert summary.normalized_score == 0.5 + assert summary.max_score == 1.0 + assert summary.metadata == {"score_scale": "normalized_0_1"} + + +def test_build_evaluation_summary_uses_full_criterion_description_field() -> None: + summary = build_evaluation_summary( + _service_result( + feedback="criterion ran", + criterion_description="The response cites official fireworks guidance.", + ), + evaluation_input=None, + ) + + entry = summary.criterion_results[0] + assert entry.criterion_description == "The response cites official fireworks guidance." + assert entry.criterion_slug == "criterion result" + + +def test_build_evaluation_summary_preserves_structured_observation() -> None: + observation = CriterionObservation( + prompt_messages=[ + CriterionObservationMessage(role="system", content="Judge this rubric."), + CriterionObservationMessage(role="user", content="Evidence payload."), + ], + evidence_resource_ids=["resource-1"], + output={"passed": True, "reasoning": "sufficient"}, + model="openai:gpt-4o", + details={"axis": "quality"}, + ) + summary = build_evaluation_summary( + _service_result( + feedback="criterion ran", + criterion_observation=observation, + evaluated_resource_ids=["resource-1"], + ), + evaluation_input=None, + ) + + entry = summary.criterion_results[0] + assert entry.observation == observation + assert entry.observation is not None + assert entry.observation.prompt_messages[1].content == "Evidence payload." + + def test_dashboard_evaluation_dto_allows_nullable_feedback_and_input() -> None: summary = build_evaluation_summary( _service_result(feedback=None), @@ -172,6 +245,7 @@ def test_dashboard_evaluation_dto_exposes_required_rubric_metadata() -> None: criterion = dto.criterion_results[0] assert dto.evaluator_name == "rubric" assert dto.aggregation_rule == "weighted_sum" + assert criterion.criterion_slug == "criterion result" assert criterion.criterion_name == "criterion result" assert criterion.status == "passed" assert criterion.passed is True diff --git a/tests/unit/runtime/test_real_llm_rollout_artifact_health.py b/tests/unit/runtime/test_real_llm_rollout_artifact_health.py index 774cf3a1..f0570a8c 100644 --- a/tests/unit/runtime/test_real_llm_rollout_artifact_health.py +++ b/tests/unit/runtime/test_real_llm_rollout_artifact_health.py @@ -17,8 +17,21 @@ def _write_minimal_rollout( *, task_count: int = 1, evaluation_rows: list[dict] | None = None, - resource_count: int = 1, + resource_rows: list[dict] | None = None, + task_execution_ids: list[str] | None = None, ) -> None: + execution_ids = task_execution_ids or [str(uuid4()) for _ in range(task_count)] + resources = resource_rows + if resources is None: + resources = [ + { + "id": str(uuid4()), + "task_execution_id": execution_ids[0], + "kind": "report", + "name": "report.md", + "file_path": "/durable/blob", + } + ] db = root / "db" db.mkdir() (root / "manifest.json").write_text( @@ -36,7 +49,7 @@ def _write_minimal_rollout( "db_row_counts": { "run_task_executions": task_count, "run_task_evaluations": len(evaluation_rows or []), - "run_resources": resource_count, + "run_resources": len(resources), "run_graph_nodes": task_count, }, } @@ -46,7 +59,7 @@ def _write_minimal_rollout( db / "run_task_executions.jsonl", [ { - "id": str(uuid4()), + "id": execution_ids[idx], "task_slug": f"task-{idx}", "status": "completed", } @@ -66,7 +79,7 @@ def _write_minimal_rollout( for idx in range(task_count) ], ) - _write_jsonl(db / "run_resources.jsonl", [{"id": str(uuid4())} for _ in range(resource_count)]) + _write_jsonl(db / "run_resources.jsonl", resources) _write_jsonl(db / "run_task_evaluations.jsonl", evaluation_rows or []) @@ -151,6 +164,104 @@ def test_artifact_health_summarizes_scores_and_workers(tmp_path: Path) -> None: assert health.worker_slugs == ["researchrubrics-researcher"] +def test_artifact_health_uses_task_scoped_report_resources(tmp_path: Path) -> None: + task_execution_id = str(uuid4()) + _write_minimal_rollout( + tmp_path, + task_count=1, + evaluation_rows=[ + { + "id": str(uuid4()), + "task_execution_id": task_execution_id, + "score": 0.75, + "summary_json": { + "evaluator_name": "research-rubric", + "normalized_score": 0.75, + "criterion_results": [ + { + "criterion_name": "criterion_0", + "criterion_type": "researchrubrics-llm-judge", + "score": 1.0, + "max_score": 1.0, + "passed": True, + "weight": 1.0, + "status": "passed", + "criterion_description": "Includes citations.", + "feedback": "The report cited source material.", + "model_reasoning": "The report cited source material.", + } + ], + }, + } + ], + resource_rows=[ + { + "id": str(uuid4()), + "task_execution_id": task_execution_id, + "kind": "report", + "name": "report.md", + "file_path": "/durable/blob/not/final_output", + "metadata_json": {"sandbox_origin": "/workspace/final_output/report.md"}, + } + ], + task_execution_ids=[task_execution_id], + ) + + health = analyze_rollout_artifacts(tmp_path, expected_task_count=1) + + assert health.missing_final_report is False + assert not any(issue.code == "missing_final_report" for issue in health.issues) + + +def test_artifact_health_flags_completed_task_without_report_resource(tmp_path: Path) -> None: + task_execution_id = str(uuid4()) + _write_minimal_rollout( + tmp_path, + task_count=1, + evaluation_rows=[ + { + "id": str(uuid4()), + "task_execution_id": task_execution_id, + "score": 0.75, + "summary_json": { + "evaluator_name": "research-rubric", + "normalized_score": 0.75, + "criterion_results": [ + { + "criterion_name": "criterion_0", + "criterion_type": "researchrubrics-llm-judge", + "score": 1.0, + "max_score": 1.0, + "passed": True, + "weight": 1.0, + "status": "passed", + "criterion_description": "Includes citations.", + "feedback": "The report cited source material.", + "model_reasoning": "The report cited source material.", + } + ], + }, + } + ], + resource_rows=[ + { + "id": str(uuid4()), + "task_execution_id": task_execution_id, + "kind": "note", + "name": "notes.md", + "file_path": "/durable/blob", + } + ], + task_execution_ids=[task_execution_id], + ) + + health = analyze_rollout_artifacts(tmp_path, expected_task_count=1) + + assert health.ok is False + assert health.missing_final_report is True + assert any(issue.code == "missing_final_report" for issue in health.issues) + + def test_rollout_report_includes_artifact_health_section(tmp_path: Path) -> None: _write_minimal_rollout( tmp_path, diff --git a/tests/unit/runtime/test_rubric_evaluation_service.py b/tests/unit/runtime/test_rubric_evaluation_service.py new file mode 100644 index 00000000..96d7a6cc --- /dev/null +++ b/tests/unit/runtime/test_rubric_evaluation_service.py @@ -0,0 +1,85 @@ +"""Contracts for rubric evaluation service spec construction.""" + +import pytest +from uuid import uuid4 + +from ergon_core.api.criterion import Criterion +from ergon_core.api.evaluation_context import EvaluationContext +from ergon_core.api.evaluator import Rubric +from ergon_core.api.results import CriterionResult, CriterionScoreSpec +from ergon_core.api.task_types import BenchmarkTask +from ergon_core.core.runtime.evaluation.evaluation_schemas import ( + CriterionSpec, + TaskEvaluationContext, +) +from ergon_core.core.runtime.services.rubric_evaluation_service import ( + RubricEvaluationService, +) + + +class _Criterion(Criterion): + type_slug = "test-criterion" + + def __init__(self, *, name: str, weight: float, max_score: float) -> None: + super().__init__( + name=name, + weight=weight, + score_spec=CriterionScoreSpec(max_score=max_score), + ) + + async def evaluate(self, context: EvaluationContext) -> CriterionResult: + return CriterionResult(name=self.name, score=self.max_score, passed=True) + + +class _Executor: + def __init__(self) -> None: + self.seen_specs: list[CriterionSpec] = [] + + async def execute_all( + self, + task_context: TaskEvaluationContext, + task: BenchmarkTask, + benchmark_name: str, + criteria: list[CriterionSpec], + ) -> list[CriterionResult]: + self.seen_specs = criteria + return [ + CriterionResult( + name=spec.criterion.name, + score=spec.max_score, + passed=True, + weight=spec.criterion.weight, + ) + for spec in criteria + ] + + +@pytest.mark.asyncio +async def test_rubric_service_uses_criterion_max_score_not_signed_weight() -> None: + executor = _Executor() + service = RubricEvaluationService(executor) + evaluator = Rubric( + name="rubric", + criteria=[ + _Criterion(name="positive", weight=2.0, max_score=2.0), + _Criterion(name="negative", weight=-5.0, max_score=5.0), + ], + ) + + await service.evaluate( + TaskEvaluationContext( + run_id=uuid4(), + task_input="", + agent_reasoning=None, + ), + evaluator, + BenchmarkTask( + task_slug="task", + instance_key="default", + description="Task", + evaluator_binding_keys=("default",), + ), + "benchmark", + ) + + assert [spec.max_score for spec in executor.seen_specs] == [2.0, 5.0] diff --git a/tests/unit/state/test_research_rubrics_benchmark.py b/tests/unit/state/test_research_rubrics_benchmark.py index 9ae8a534..10aa6515 100644 --- a/tests/unit/state/test_research_rubrics_benchmark.py +++ b/tests/unit/state/test_research_rubrics_benchmark.py @@ -1,16 +1,70 @@ """Tests for ResearchRubrics benchmark registration and vanilla variant.""" +from datetime import UTC, datetime +from uuid import uuid4 + import pytest from ergon_builtins.benchmarks.researchrubrics.benchmark import ResearchRubricsBenchmark +from ergon_builtins.benchmarks.researchrubrics.judge_criterion import ( + ResearchRubricsJudgeCriterion, +) from ergon_builtins.benchmarks.researchrubrics.rubric import ResearchRubricsRubric -from ergon_builtins.benchmarks.researchrubrics.task_schemas import ResearchRubricsTaskPayload +from ergon_builtins.benchmarks.researchrubrics.task_schemas import ( + ResearchRubricsTaskPayload, + RubricCriterion, +) from ergon_builtins.benchmarks.researchrubrics.vanilla import ResearchRubricsVanillaBenchmark from ergon_builtins.registry_data import BENCHMARKS, EVALUATORS, WORKERS from ergon_core.api import Benchmark -from ergon_core.api.results import CriterionResult +from ergon_core.api.evaluation_context import EvaluationContext +from ergon_core.api.results import CriterionResult, WorkerOutput +from ergon_core.core.runtime.resources import RunResourceKind, RunResourceView from ergon_core.api.task_types import BenchmarkTask +class _FakeJudgeRuntime: + def __init__(self, resources: list[RunResourceView], blobs: dict[str, bytes]) -> None: + self._resources = resources + self._blobs = blobs + self.listed_task_execution_ids: list[object] = [] + self.read_resource_ids: list[str] = [] + + async def list_resources(self, task_execution_id=None): + self.listed_task_execution_ids.append(task_execution_id) + return self._resources + + async def read_resource_by_id(self, resource_id): + self.read_resource_ids.append(str(resource_id)) + return self._blobs[str(resource_id)] + + +def _resource_view( + *, + kind: RunResourceKind, + name: str, + sandbox_origin: str, + text: str, +) -> tuple[RunResourceView, bytes]: + resource_id = uuid4() + return ( + RunResourceView( + id=resource_id, + run_id=uuid4(), + task_execution_id=uuid4(), + kind=kind, + name=name, + mime_type="text/markdown", + file_path=f"/durable/{resource_id}", + size_bytes=len(text.encode()), + content_hash=None, + error=None, + metadata={"sandbox_origin": sandbox_origin}, + created_at=datetime.now(UTC), + ), + text.encode(), + ) + + class TestResearchRubricsBenchmarkRegistration: """Verify benchmark slugs resolve correctly in the registry.""" @@ -159,6 +213,11 @@ def test_can_construct_without_prebound_criteria(self): criteria = list(rubric.criteria_for(task)) assert [criterion.weight for criterion in criteria] == [2.0, -1.0] + assert [criterion.score_spec.max_score for criterion in criteria] == [2.0, 1.0] + assert [criterion.description for criterion in criteria] == [ + "Includes citations.", + "No unsupported claims.", + ] assert [type(criterion).__name__ for criterion in criteria] == [ "ResearchRubricsJudgeCriterion", "ResearchRubricsJudgeCriterion", @@ -186,6 +245,91 @@ def test_aggregate_uses_result_weights(self): assert result.score == 1.0 assert result.metadata == { "total_score": 2.0, + "score_scale": "normalized_0_1", + "raw_score": 2.0, "max_possible": 2.0, "min_possible": -1.0, } + + +class TestResearchRubricsJudgeCriterion: + @pytest.mark.asyncio + async def test_judge_prioritizes_final_resources_over_final_message(self) -> None: + final_resource, final_blob = _resource_view( + kind=RunResourceKind.REPORT, + name="report.md", + sandbox_origin="/workspace/final_output/report.md", + text="# Final report\nThis is the primary answer artifact.", + ) + scratch_resource, scratch_blob = _resource_view( + kind=RunResourceKind.NOTE, + name="notes.md", + sandbox_origin="/workspace/notes.md", + text="scratch notes", + ) + runtime = _FakeJudgeRuntime( + resources=[scratch_resource, final_resource], + blobs={ + str(final_resource.id): final_blob, + str(scratch_resource.id): scratch_blob, + }, + ) + context = EvaluationContext( + run_id=uuid4(), + task_id=uuid4(), + execution_id=uuid4(), + task=BenchmarkTask( + task_slug="sample", + instance_key="default", + description="Write a report.", + evaluator_binding_keys=("default",), + ), + worker_result=WorkerOutput(output="assistant summary only"), + runtime=runtime, + ) + + class Criterion(ResearchRubricsJudgeCriterion): + async def _call_judge(self, *, system_prompt: str, user_prompt: str): + self.captured_user_prompt = user_prompt + from ergon_builtins.benchmarks.researchrubrics.judge_criterion import ( + ResearchRubricsVerdict, + ) + + return ResearchRubricsVerdict( + passed=True, + reasoning="The final report satisfies the criterion.", + ) + + criterion = Criterion( + slug="includes_findings", + rubric=RubricCriterion( + criterion="Includes findings", + axis="quality", + weight=1.0, + ), + ) + + result = await criterion.evaluate(context) + + assert runtime.listed_task_execution_ids == [None] + assert set(runtime.read_resource_ids) == { + str(final_resource.id), + str(scratch_resource.id), + } + assert result.evaluated_resource_ids == [ + str(final_resource.id), + str(scratch_resource.id), + ] + assert result.slug == "includes_findings" + assert result.observation is not None + assert result.observation.evidence_resource_ids == result.evaluated_resource_ids + assert result.observation.output == { + "passed": True, + "reasoning": "The final report satisfies the criterion.", + } + assert result.evaluation_input is not None + assert "Final output resources" in result.evaluation_input + assert "Scratch / supporting resources" in result.evaluation_input + assert "Final assistant message" in result.evaluation_input + assert "This is the primary answer artifact." in criterion.captured_user_prompt + assert "assistant summary only" in criterion.captured_user_prompt diff --git a/tests/unit/state/test_research_rubrics_workers.py b/tests/unit/state/test_research_rubrics_workers.py index af8e594b..dcb03a78 100644 --- a/tests/unit/state/test_research_rubrics_workers.py +++ b/tests/unit/state/test_research_rubrics_workers.py @@ -10,13 +10,6 @@ from uuid import uuid4 import pytest -from ergon_builtins.workers.research_rubrics.researcher_worker import ( - ResearchRubricsResearcherWorker, -) -from ergon_builtins.workers.research_rubrics.workflow_cli_react_worker import ( - _WORKFLOW_PROMPT, - ResearchRubricsWorkflowCliReActWorker, -) from ergon_builtins.benchmarks.researchrubrics.toolkit_types import ( ReportReadSuccess, ReportWriteSuccess, @@ -25,7 +18,14 @@ ReportReadSkillRequest, ReportWriteSkillRequest, ) -from ergon_core.api.generation import GenerationTurn +from ergon_builtins.workers.research_rubrics.researcher_worker import ( + ResearchRubricsResearcherWorker, +) +from ergon_builtins.workers.research_rubrics.workflow_cli_react_worker import ( + _WORKFLOW_PROMPT, + ResearchRubricsWorkflowCliReActWorker, +) +from ergon_core.core.generation import GenerationTurn from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext @@ -160,9 +160,19 @@ async def test_workflow_cli_worker_adds_workflow_tool(self): def test_workflow_cli_prompt_exposes_real_subtask_creation(self): assert "manage add-task" in _WORKFLOW_PROMPT - assert "researchrubrics-researcher" in _WORKFLOW_PROMPT + assert "--worker researchrubrics-workflow-cli-react" in _WORKFLOW_PROMPT + assert "same decision policy applies recursively" in _WORKFLOW_PROMPT assert "--dry-run" in _WORKFLOW_PROMPT + def test_workflow_cli_prompt_guides_recursive_task_graph_decision(self): + assert "At the start of your task" in _WORKFLOW_PROMPT + assert "inspect task-tree --format json" in _WORKFLOW_PROMPT + assert "inspect next-actions --manager-capable" in _WORKFLOW_PROMPT + assert "decide whether to solve directly or create subtasks" in _WORKFLOW_PROMPT + assert "independent evidence-gathering or checking efforts" in _WORKFLOW_PROMPT + assert "wait for them to finish before final synthesis" in _WORKFLOW_PROMPT + assert "replacement task with a narrower scope" in _WORKFLOW_PROMPT + @pytest.mark.asyncio async def test_report_write_uses_manager_public_file_api(self): task_id = uuid4() From b4230973f4874abffa573525c5df668331a72828 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:22:26 +0100 Subject: [PATCH 16/66] chore: apply lint and test cleanup Normalize imports, typing, and small test helpers so the branch has a stable baseline for the schema refactor. Made-with: Cursor --- .gitignore | 1 + docker-compose.yml | 6 + .../src/components/run/RunWorkspacePage.tsx | 1 - .../benchmarks/gdpeval/benchmark.py | 2 +- .../benchmarks/minif2f/benchmark.py | 3 +- .../minif2f/rules/proof_verification.py | 12 +- .../benchmarks/swebench_verified/benchmark.py | 1 - .../swebench_verified/task_schemas.py | 20 +- .../evaluators/criteria/code_check.py | 16 +- .../evaluators/criteria/llm_judge.py | 19 +- .../models/transformers_backend.py | 3 +- .../ergon_builtins/models/vllm_backend.py | 3 +- ergon_builtins/ergon_builtins/registry.py | 4 +- .../ergon_builtins/registry_core.py | 5 +- .../ergon_builtins/registry_local_models.py | 1 - .../ergon_builtins/tools/graph_toolkit.py | 80 ++++++-- .../tools/subtask_lifecycle_toolkit.py | 7 +- .../ergon_builtins/tools/workflow_cli_tool.py | 32 ++- ergon_builtins/pyproject.toml | 1 + ergon_cli/ergon_cli/commands/run.py | 8 +- ergon_cli/ergon_cli/main.py | 2 +- ergon_cli/ergon_cli/onboarding/env_writer.py | 1 - ergon_cli/ergon_cli/onboarding/profile.py | 3 +- ergon_core/ergon_core/api/results.py | 54 +++++- ergon_core/ergon_core/core/api/app.py | 3 +- ergon_core/ergon_core/core/api/runs.py | 2 + ergon_core/ergon_core/core/api/schemas.py | 2 + .../ergon_core/core/dashboard/emitter.py | 4 +- .../core/dashboard/event_contracts.py | 21 +- .../persistence/context/event_payloads.py | 90 +++++++-- .../core/persistence/context/models.py | 15 +- .../core/persistence/context/repository.py | 2 +- .../core/persistence/definitions/models.py | 2 +- .../core/persistence/graph/models.py | 82 +++++--- .../ergon_core/core/persistence/queries.py | 2 +- .../core/persistence/saved_specs/models.py | 2 +- .../persistence/telemetry/repositories.py | 2 +- .../providers/generation/openrouter_budget.py | 1 - .../core/providers/judges/__init__.py | 0 .../core/runtime/errors/error_payload.py | 2 +- .../runtime/evaluation/evaluation_schemas.py | 2 +- .../runtime/evaluation/inngest_executor.py | 2 +- .../core/runtime/execution/propagation.py | 2 +- .../runtime/inngest/cancel_orphan_subtasks.py | 1 - .../runtime/inngest/cleanup_cancelled_task.py | 3 +- .../core/runtime/inngest/evaluate_task_run.py | 12 +- .../core/runtime/inngest/execute_task.py | 6 +- .../runtime/inngest/propagate_execution.py | 1 - .../core/runtime/inngest/worker_execute.py | 4 +- .../core/runtime/inngest_registry.py | 2 +- .../core/runtime/services/cohort_schemas.py | 2 +- .../core/runtime/services/cohort_service.py | 6 +- .../core/runtime/services/evaluation_dto.py | 2 +- .../evaluation_persistence_service.py | 20 +- .../experiment_persistence_service.py | 4 +- .../core/runtime/services/graph_dto.py | 34 +++- .../core/runtime/services/graph_repository.py | 3 +- .../services/inngest_function_results.py | 2 +- .../runtime/services/orchestration_dto.py | 2 +- .../core/runtime/services/run_service.py | 2 +- .../services/subtask_blocking_service.py | 3 +- .../services/subtask_cancellation_dto.py | 3 +- .../services/subtask_cancellation_service.py | 3 +- .../core/runtime/services/task_cleanup_dto.py | 3 +- .../runtime/services/task_cleanup_service.py | 3 +- .../runtime/services/task_inspection_dto.py | 3 +- .../services/task_inspection_service.py | 3 +- .../runtime/services/task_management_dto.py | 4 +- .../services/task_propagation_service.py | 2 +- ergon_core/ergon_core/core/runtime/tracing.py | 3 +- .../test_support/smoke_fixtures/__init__.py | 1 - .../test_support/smoke_fixtures/benchmarks.py | 12 +- .../smoke_fixtures/criteria/minif2f_smoke.py | 3 +- .../criteria/researchrubrics_smoke.py | 3 +- .../smoke_fixtures/criteria/smoke_rubrics.py | 1 - .../smoke_fixtures/criteria/swebench_smoke.py | 3 +- .../smoke_base/criterion_base.py | 7 +- .../smoke_fixtures/smoke_base/sadpath.py | 1 - .../smoke_fixtures/workers/minif2f_smoke.py | 1 - .../workers/researchrubrics_smoke.py | 1 - .../workers/researchrubrics_smoke_sadpath.py | 1 - .../smoke_fixtures/workers/swebench_smoke.py | 1 - ...36e45e5e6_add_containment_and_cancelled.py | 2 +- ...69899_rename_task_key_to_task_slug_and_.py | 1 - ergon_infra/ergon_infra/training/callback.py | 5 +- scripts/check_suppression_budget.py | 1 - scripts/train_trl_grpo.py | 1 - tests/e2e/_asserts.py | 9 +- tests/e2e/test_minif2f_smoke.py | 2 +- tests/e2e/test_swebench_smoke.py | 2 +- .../minif2f/test_sandbox_manager.py | 4 +- .../minif2f/test_verification_integration.py | 3 +- tests/integration/propagation/_helpers.py | 3 +- .../propagation/test_propagation_blocked.py | 7 +- .../propagation/test_propagation_cancel.py | 3 +- .../test_propagation_edge_cases.py | 5 +- .../propagation/test_propagation_happy.py | 7 +- .../propagation/test_propagation_restart.py | 4 +- tests/integration/restart/_helpers.py | 3 +- .../restart/test_downstream_invalidation.py | 5 +- .../restart/test_manager_dag_scenario.py | 2 +- .../integration/restart/test_reactivation.py | 4 +- .../integration/restart/test_restart_task.py | 5 +- .../sandbox/test_required_env_keys.py | 1 - .../integration/smokes/test_smoke_harness.py | 5 +- .../integration/swebench_verified/conftest.py | 3 +- .../swebench_verified/test_benchmark.py | 2 +- .../swebench_verified/test_sandbox_manager.py | 4 +- .../swebench_verified/test_task_schemas.py | 2 - .../swebench_verified/test_toolkit.py | 1 - tests/real_llm/benchmarks/test_smoke_stub.py | 3 +- tests/real_llm/fixtures/openrouter_budget.py | 1 - tests/real_llm/rollout.py | 13 +- .../test_no_test_logic_in_core.py | 1 - .../test_persistence_boundaries.py | 1 - .../test_swebench_sandbox_manager.py | 1 - .../builtins/common/test_capture_settings.py | 28 ++- tests/unit/cli/test_benchmark_setup.py | 3 +- .../unit/cli/test_eval_cli_required_fields.py | 1 - tests/unit/cli/test_workflow_cli.py | 104 ++++++++-- .../test_context_event_repository.py | 4 +- tests/unit/registry/test_react_factories.py | 5 +- .../runtime/test_communication_service.py | 5 +- .../test_criterion_runtime_get_all_files.py | 1 - .../test_criterion_runtime_reconnect.py | 1 - .../test_definition_task_payload_typing.py | 3 +- .../test_failed_task_sandbox_cleanup.py | 1 - tests/unit/runtime/test_failure_error_json.py | 1 - .../runtime/test_run_record_missing_error.py | 1 - .../unit/runtime/test_smoke_topology_drift.py | 2 +- .../test_ensure_sandbox_idempotence.py | 1 - .../sandbox/test_sandbox_lifecycle_service.py | 1 - tests/unit/sandbox/test_sandbox_reconnect.py | 1 - .../smoke_base/test_always_fail_subworker.py | 1 - .../test_leaf_sends_completion_message.py | 1 - .../unit/smoke_base/test_minif2f_criterion.py | 1 - .../test_recursive_smoke_worker_routing.py | 1 - .../smoke_base/test_registry_smoke_entries.py | 10 +- .../test_researchrubrics_criterion.py | 1 - .../smoke_base/test_sadpath_worker_routing.py | 3 +- .../test_smoke_criterion_completed.py | 3 +- .../smoke_base/test_smoke_criterion_probe.py | 3 +- .../smoke_base/test_smoke_criterion_shape.py | 3 +- .../smoke_base/test_smoke_sandbox_manager.py | 3 +- .../test_smoke_worker_base_final.py | 1 - .../smoke_base/test_swebench_criterion.py | 1 - tests/unit/state/test_benchmark_contract.py | 4 +- .../state/test_openrouter_model_resolution.py | 17 +- .../state/test_subtask_lifecycle_toolkit.py | 1 - tests/unit/state/test_type_invariants.py | 5 +- tests/unit/state/test_workflow_cli_tool.py | 48 ++++- tests/unit/test_dashboard_emitter_wiring.py | 1 - tests/unit/test_openrouter_budget.py | 3 +- tests/unit/test_test_harness.py | 5 +- .../workers/test_react_worker_contract.py | 183 +++++++++++++++++- uv.lock | 2 + 156 files changed, 896 insertions(+), 378 deletions(-) delete mode 100644 ergon_core/ergon_core/core/providers/judges/__init__.py diff --git a/.gitignore b/.gitignore index 6e6134c7..7b081c5f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ build/ # Environment .env +.logfire/ # Databases *.db diff --git a/docker-compose.yml b/docker-compose.yml index a2abfdb6..39f8be13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,11 +81,16 @@ services: - INNGEST_API_BASE_URL=http://inngest-dev:8288 - ERGON_API_BASE_URL=http://api:9000 - ENABLE_TEST_HARNESS=${ENABLE_TEST_HARNESS:-0} + - ENABLE_SMOKE_FIXTURES=${ENABLE_SMOKE_FIXTURES:-0} - ERGON_STARTUP_PLUGINS=${ERGON_STARTUP_PLUGINS:-} - TEST_HARNESS_SECRET=${TEST_HARNESS_SECRET:-local-dev} - ERGON_BLOB_ROOT=/tmp/ergon-blob - OTEL_TRACES_ENABLED=false - OTEL_SERVICE_NAME=ergon-core + - ERGON_LOGFIRE_PYDANTIC_AI=${ERGON_LOGFIRE_PYDANTIC_AI:-0} + - ERGON_LOGFIRE_CONFIG_DIR=${ERGON_LOGFIRE_CONFIG_DIR:-/app/.logfire} + - ERGON_LOGFIRE_SERVICE_NAME=${ERGON_LOGFIRE_SERVICE_NAME:-ergon-builtins} + - ERGON_LOGFIRE_ENVIRONMENT=${ERGON_LOGFIRE_ENVIRONMENT:-local} - E2B_API_KEY=${E2B_API_KEY:-} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} # Put /app on sys.path so editable source mounts resolve in the API @@ -104,6 +109,7 @@ services: - ./ergon_builtins:/app/ergon_builtins - ./ergon_cli:/app/ergon_cli - ./data:/app/data + - ./.logfire:/app/.logfire:ro # Blob store bind mount so host-side pytest can read files the # SandboxResourcePublisher writes from inside the container. The # mount uses the same path on both sides so the DB-stored diff --git a/ergon-dashboard/src/components/run/RunWorkspacePage.tsx b/ergon-dashboard/src/components/run/RunWorkspacePage.tsx index 659c5396..b41dc651 100644 --- a/ergon-dashboard/src/components/run/RunWorkspacePage.tsx +++ b/ergon-dashboard/src/components/run/RunWorkspacePage.tsx @@ -27,7 +27,6 @@ const VERTICAL_LAYOUT_STORAGE_KEY = "ergon-run-debugger-vertical-layout:v1"; const HORIZONTAL_LAYOUT_STORAGE_KEY = "ergon-run-debugger-horizontal-layout:v1"; const DEFAULT_VERTICAL_LAYOUT: Layout = { "graph-workspace": 62, timeline: 38 }; const DEFAULT_HORIZONTAL_LAYOUT: Layout = { graph: 58, workspace: 42 }; -const FULL_GRAPH_LAYOUT: Layout = { graph: 100, "graph-workspace": 100 }; function loadPanelLayout(storageKey: string, fallback: Layout): Layout { if (typeof window === "undefined") return fallback; diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/benchmark.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/benchmark.py index d5b5d264..3468f100 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/benchmark.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/benchmark.py @@ -12,13 +12,13 @@ from ergon_core.api.benchmark_deps import BenchmarkDeps from ergon_core.api.task_types import BenchmarkTask -from ergon_builtins.benchmarks.gdpeval.task_schemas import GDPTaskConfig from ergon_builtins.benchmarks.gdpeval.loader import ( HF_REPO_ID, extract_task_description, find_reference_files, load_task_ids, ) +from ergon_builtins.benchmarks.gdpeval.task_schemas import GDPTaskConfig class GDPEvalBenchmark(Benchmark): diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/benchmark.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/benchmark.py index 6bacfad1..bf7fa1eb 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/benchmark.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/benchmark.py @@ -11,11 +11,10 @@ from pathlib import Path from typing import Any, ClassVar -from huggingface_hub import hf_hub_download - from ergon_core.api.benchmark import Benchmark from ergon_core.api.benchmark_deps import BenchmarkDeps from ergon_core.api.task_types import BenchmarkTask +from huggingface_hub import hf_hub_download from ergon_builtins.benchmarks.minif2f.task_schemas import MiniF2FProblem, MiniF2FTaskPayload diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py index fad39c44..4afc0abc 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py @@ -11,7 +11,7 @@ from ergon_core.api.criterion import Criterion from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult +from ergon_core.api.results import CriterionResult, CriterionScoreSpec from ergon_core.core.runtime.evaluation.criterion_runtime import ResourceNotFoundError from pydantic import BaseModel @@ -59,8 +59,11 @@ def __init__( ground_truth_proof: str | None = None, formal_system: str = "lean", ) -> None: - super().__init__(name=name, weight=weight) - self.max_score = max_score + super().__init__( + name=name, + weight=weight, + score_spec=CriterionScoreSpec(max_score=max_score), + ) self.problem_statement = problem_statement self.ground_truth_proof = ground_truth_proof self.formal_system = formal_system @@ -90,7 +93,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: ) outcome = await self._verify_proof(context, proof_code) - score = self.max_score if outcome.verified else 0.0 + score = self.score_spec.max_score if outcome.verified else 0.0 feedback = ( "Proof successfully verified by Lean compiler." if outcome.verified @@ -102,6 +105,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: score=score, passed=outcome.verified, weight=self.weight, + max_score=self.score_spec.max_score, feedback=feedback, evaluation_input=evaluation_log, evaluated_resource_ids=proof_data.evaluated_resource_ids, diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/benchmark.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/benchmark.py index 6314e483..31e09b66 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/benchmark.py +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/benchmark.py @@ -10,7 +10,6 @@ from typing import Any, ClassVar from datasets import load_dataset - from ergon_core.api.benchmark import Benchmark from ergon_core.api.benchmark_deps import BenchmarkDeps from ergon_core.api.task_types import BenchmarkTask diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/task_schemas.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/task_schemas.py index 2c0d190e..c0962d73 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/task_schemas.py +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/task_schemas.py @@ -20,9 +20,13 @@ class SWEBenchInstance(BaseModel): repo: str base_commit: str problem_statement: str - # HF dataset schema guarantees hints_text is a str; `.strip()` depends - # on it never being None. - hints_text: str = "" # slopcop: ignore[no-str-empty-default] + hints_text: str = Field( # slopcop: ignore[no-str-empty-default] + default="", + description=( + "Hint text normalized from the HuggingFace dataset as a non-null string " + "because downstream prompt rendering calls .strip()." + ), + ) version: str fail_to_pass: list[str] pass_to_pass: list[str] @@ -61,9 +65,13 @@ class SWEBenchTaskPayload(BaseModel): base_commit: str version: str problem_statement: str - # Mirrors SWEBenchInstance.hints_text; empty string is the documented - # dataset default when hints are absent. - hints_text: str = "" # slopcop: ignore[no-str-empty-default] + hints_text: str = Field( # slopcop: ignore[no-str-empty-default] + default="", + description=( + "Hint text mirrored from SWEBenchInstance. Empty string is the dataset default " + "when hints are absent." + ), + ) fail_to_pass: list[str] pass_to_pass: list[str] environment_setup_commit: str diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py b/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py index 4aa5e778..4842fa5f 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py @@ -9,7 +9,7 @@ from ergon_core.api.criterion import Criterion from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult +from ergon_core.api.results import CriterionResult, CriterionScoreSpec class CodeCheckCriterion(Criterion): @@ -32,19 +32,23 @@ def __init__( weight: float = 1.0, max_score: float = 1.0, ) -> None: - super().__init__(name=name, weight=weight) + super().__init__( + name=name, + description=description or name, + weight=weight, + score_spec=CriterionScoreSpec(max_score=max_score), + ) self.code_template = code_template - self.description = description - self.max_score = max_score async def evaluate(self, context: EvaluationContext) -> CriterionResult: output = context.worker_result.output passed = bool(output and len(output.strip()) > 0) - score = self.max_score if passed else 0.0 + score = self.score_spec.max_score if passed else 0.0 return CriterionResult( - name=self.name, + slug=self.slug, score=score, passed=passed, weight=self.weight, + max_score=self.score_spec.max_score, feedback=f"Code check '{self.name}': {'passed' if passed else 'failed'}", ) diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py index f119be21..81969f85 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py @@ -10,12 +10,13 @@ from ergon_core.api.criterion import Criterion from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult +from ergon_core.api.results import CriterionResult, CriterionScoreSpec +from pydantic import BaseModel + from ergon_builtins.common.llm.structured_judge import ( JudgeMessage, call_structured_judge, ) -from pydantic import BaseModel class _JudgeVerdict(BaseModel): @@ -44,10 +45,13 @@ def __init__( max_score: float = 1.0, model: str = "gpt-4o", ) -> None: - super().__init__(name=name, weight=weight) + super().__init__( + name=name, + description=description or name, + weight=weight, + score_spec=CriterionScoreSpec(max_score=max_score), + ) self.prompt_template = prompt_template - self.description = description - self.max_score = max_score self.model = model async def evaluate(self, context: EvaluationContext) -> CriterionResult: @@ -68,11 +72,12 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: model=self.model, ) - score = self.max_score if verdict.passed else 0.0 + score = self.score_spec.max_score if verdict.passed else 0.0 return CriterionResult( - name=self.name, + slug=self.slug, score=score, passed=verdict.passed, weight=self.weight, + max_score=self.score_spec.max_score, feedback=verdict.reasoning, ) diff --git a/ergon_builtins/ergon_builtins/models/transformers_backend.py b/ergon_builtins/ergon_builtins/models/transformers_backend.py index 2f99011d..770e3dcc 100644 --- a/ergon_builtins/ergon_builtins/models/transformers_backend.py +++ b/ergon_builtins/ergon_builtins/models/transformers_backend.py @@ -15,9 +15,10 @@ import pydantic_ai.messages as _messages import pydantic_ai.models as _models import torch +from pydantic_ai.settings import ModelSettings from transformers import AutoModelForCausalLM, AutoTokenizer + from ergon_builtins.models.resolution import ResolvedModel -from pydantic_ai.settings import ModelSettings logger = logging.getLogger(__name__) diff --git a/ergon_builtins/ergon_builtins/models/vllm_backend.py b/ergon_builtins/ergon_builtins/models/vllm_backend.py index 720ee83d..7c49526d 100644 --- a/ergon_builtins/ergon_builtins/models/vllm_backend.py +++ b/ergon_builtins/ergon_builtins/models/vllm_backend.py @@ -5,10 +5,11 @@ import urllib.error import urllib.request -from ergon_builtins.models.resolution import ResolvedModel from pydantic_ai.models.openai import OpenAIModel as OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider +from ergon_builtins.models.resolution import ResolvedModel + logger = logging.getLogger(__name__) diff --git a/ergon_builtins/ergon_builtins/registry.py b/ergon_builtins/ergon_builtins/registry.py index 0a6905fb..97dc5917 100644 --- a/ergon_builtins/ergon_builtins/registry.py +++ b/ergon_builtins/ergon_builtins/registry.py @@ -8,12 +8,12 @@ import structlog from ergon_core.api import Benchmark, Evaluator, Worker +from ergon_core.core.providers.sandbox.manager import BaseSandboxManager + from ergon_builtins.models.resolution import ( ResolvedModel, register_model_backend, ) -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager - from ergon_builtins.registry_core import ( BENCHMARKS as _core_benchmarks, ) diff --git a/ergon_builtins/ergon_builtins/registry_core.py b/ergon_builtins/ergon_builtins/registry_core.py index 78288c90..3c599194 100644 --- a/ergon_builtins/ergon_builtins/registry_core.py +++ b/ergon_builtins/ergon_builtins/registry_core.py @@ -10,7 +10,6 @@ from uuid import UUID from ergon_core.api import Benchmark, Evaluator, Worker -from ergon_builtins.models.resolution import ResolvedModel from ergon_core.core.providers.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.gdpeval.rubric import StagedRubric @@ -27,6 +26,8 @@ from ergon_builtins.evaluators.rubrics.swebench_rubric import SWEBenchRubric from ergon_builtins.models.cloud_passthrough import resolve_cloud from ergon_builtins.models.openrouter_backend import resolve_openrouter +from ergon_builtins.models.openrouter_responses_backend import resolve_openrouter_responses +from ergon_builtins.models.resolution import ResolvedModel from ergon_builtins.models.vllm_backend import resolve_vllm from ergon_builtins.workers.baselines.react_prompts import ( MINIF2F_SYSTEM_PROMPT, @@ -35,7 +36,6 @@ from ergon_builtins.workers.baselines.react_worker import ReActWorker from ergon_builtins.workers.baselines.training_stub_worker import TrainingStubWorker - # reason: Worker factory signature — every registry entry accepts the same # four keyword-only args. Plain ``Worker`` subclasses get them via # ``super().__init__``; benchmark factories read ``task_id`` to resolve a @@ -186,4 +186,5 @@ def _swebench_react( "anthropic": resolve_cloud, "google": resolve_cloud, "openrouter": resolve_openrouter, + "openai-responses": resolve_openrouter_responses, } diff --git a/ergon_builtins/ergon_builtins/registry_local_models.py b/ergon_builtins/ergon_builtins/registry_local_models.py index 15fec1aa..5632f688 100644 --- a/ergon_builtins/ergon_builtins/registry_local_models.py +++ b/ergon_builtins/ergon_builtins/registry_local_models.py @@ -8,7 +8,6 @@ from collections.abc import Callable from ergon_builtins.models.resolution import ResolvedModel - from ergon_builtins.models.transformers_backend import resolve_transformers MODEL_BACKENDS: dict[str, Callable[..., ResolvedModel]] = { diff --git a/ergon_builtins/ergon_builtins/tools/graph_toolkit.py b/ergon_builtins/ergon_builtins/tools/graph_toolkit.py index dc350335..804fa669 100644 --- a/ergon_builtins/ergon_builtins/tools/graph_toolkit.py +++ b/ergon_builtins/ergon_builtins/tools/graph_toolkit.py @@ -10,13 +10,14 @@ from ergon_core.core.persistence.queries import queries from ergon_core.core.persistence.telemetry.models import RunResource +from pydantic_ai import RunContext +from pydantic_ai.tools import Tool from ergon_builtins.tools.graph_toolkit_types import ResourceRef - -try: - from pydantic_ai.tools import Tool -except ImportError: # pragma: no cover — defensive - Tool = None # type: ignore[assignment,misc] +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetExhaustedResult, +) class ResearchGraphToolkit: @@ -51,18 +52,26 @@ def _list_my_resources(self) -> "Tool": run_id = self._run_id task_execution_id = self._task_execution_id - async def list_my_resources() -> list[ResourceRef]: + async def list_my_resources( + ctx: "RunContext[AgentToolBudgetDeps]", + ) -> list[ResourceRef] | AgentToolBudgetExhaustedResult: """List resources produced by my own task execution. Returns resources in most-recently-created-first order. Only resources belonging to this run are included. """ + tool_budget = ctx.deps.tool_budget + if ( + tool_budget.increment("list_my_resources", "other") + > tool_budget.max_other_tool_calls + ): + return tool_budget.exhausted_result("non-workflow tool budget reached") rows = queries.resources.list_by_execution(task_execution_id) return _to_refs_sorted( [r for r in rows if r.run_id == run_id], ) - return Tool(function=list_my_resources, takes_ctx=False) + return Tool(function=list_my_resources, takes_ctx=True) # ------------------------------------------------------------------ # list_child_resources @@ -72,12 +81,20 @@ def _list_child_resources(self) -> "Tool": run_id = self._run_id task_execution_id = self._task_execution_id - async def list_child_resources() -> list[ResourceRef]: + async def list_child_resources( + ctx: "RunContext[AgentToolBudgetDeps]", + ) -> list[ResourceRef] | AgentToolBudgetExhaustedResult: """List resources produced by direct child task executions. Only returns resources from immediate children — not grandchildren or deeper descendants. """ + tool_budget = ctx.deps.tool_budget + if ( + tool_budget.increment("list_child_resources", "other") + > tool_budget.max_other_tool_calls + ): + return tool_budget.exhausted_result("non-workflow tool budget reached") children = queries.task_executions.list_children_of(task_execution_id) result: list[RunResource] = [] for child in children: @@ -85,7 +102,7 @@ async def list_child_resources() -> list[ResourceRef]: result.extend(r for r in rows if r.run_id == run_id) return _to_refs_sorted(result) - return Tool(function=list_child_resources, takes_ctx=False) + return Tool(function=list_child_resources, takes_ctx=True) # ------------------------------------------------------------------ # list_descendant_resources @@ -96,8 +113,9 @@ def _list_descendant_resources(self) -> "Tool": task_execution_id = self._task_execution_id async def list_descendant_resources( + ctx: "RunContext[AgentToolBudgetDeps]", max_depth: int = 3, - ) -> list[ResourceRef]: + ) -> list[ResourceRef] | AgentToolBudgetExhaustedResult: """List resources from descendant task executions (BFS). Traverses child task executions up to *max_depth* levels deep, @@ -107,6 +125,12 @@ async def list_descendant_resources( Args: max_depth: Maximum depth of BFS traversal (default 3). """ + tool_budget = ctx.deps.tool_budget + if ( + tool_budget.increment("list_descendant_resources", "other") + > tool_budget.max_other_tool_calls + ): + return tool_budget.exhausted_result("non-workflow tool budget reached") visited: set[UUID] = {task_execution_id} frontier: list[UUID] = [task_execution_id] result: list[RunResource] = [] @@ -130,7 +154,7 @@ async def list_descendant_resources( return _to_refs_sorted(result) - return Tool(function=list_descendant_resources, takes_ctx=False) + return Tool(function=list_descendant_resources, takes_ctx=True) # ------------------------------------------------------------------ # list_run_resources @@ -139,16 +163,24 @@ async def list_descendant_resources( def _list_run_resources(self) -> "Tool": run_id = self._run_id - async def list_run_resources() -> list[ResourceRef]: + async def list_run_resources( + ctx: "RunContext[AgentToolBudgetDeps]", + ) -> list[ResourceRef] | AgentToolBudgetExhaustedResult: """List all resources in this run. Returns every resource row belonging to the current run, in most-recently-created-first order. """ + tool_budget = ctx.deps.tool_budget + if ( + tool_budget.increment("list_run_resources", "other") + > tool_budget.max_other_tool_calls + ): + return tool_budget.exhausted_result("non-workflow tool budget reached") rows = queries.resources.list_by_run(run_id) return _to_refs_sorted(rows) - return Tool(function=list_run_resources, takes_ctx=False) + return Tool(function=list_run_resources, takes_ctx=True) # ------------------------------------------------------------------ # get_resource_by_logical_path @@ -158,8 +190,9 @@ def _get_resource_by_logical_path(self) -> "Tool": run_id = self._run_id async def get_resource_by_logical_path( + ctx: "RunContext[AgentToolBudgetDeps]", logical_path: str, - ) -> ResourceRef | None: + ) -> ResourceRef | AgentToolBudgetExhaustedResult | None: """Look up the latest resource by its logical path (file_path). Scoped to this run. Returns the most recently created resource @@ -168,6 +201,12 @@ async def get_resource_by_logical_path( Args: logical_path: The file_path of the resource to look up. """ + tool_budget = ctx.deps.tool_budget + if ( + tool_budget.increment("get_resource_by_logical_path", "other") + > tool_budget.max_other_tool_calls + ): + return tool_budget.exhausted_result("non-workflow tool budget reached") rows = queries.resources.list_by_run(run_id) matching = [r for r in rows if r.file_path == logical_path] if not matching: @@ -175,7 +214,7 @@ async def get_resource_by_logical_path( matching.sort(key=lambda r: (r.created_at, r.id), reverse=True) return ResourceRef.from_row(matching[0]) - return Tool(function=get_resource_by_logical_path, takes_ctx=False) + return Tool(function=get_resource_by_logical_path, takes_ctx=True) # ------------------------------------------------------------------ # get_resource_by_content_hash @@ -185,8 +224,9 @@ def _get_resource_by_content_hash(self) -> "Tool": run_id = self._run_id async def get_resource_by_content_hash( + ctx: "RunContext[AgentToolBudgetDeps]", content_hash: str, - ) -> ResourceRef | None: + ) -> ResourceRef | AgentToolBudgetExhaustedResult | None: """Look up the latest resource by its content hash. Scoped to this run. Returns the most recently created resource @@ -195,6 +235,12 @@ async def get_resource_by_content_hash( Args: content_hash: The content hash to search for. """ + tool_budget = ctx.deps.tool_budget + if ( + tool_budget.increment("get_resource_by_content_hash", "other") + > tool_budget.max_other_tool_calls + ): + return tool_budget.exhausted_result("non-workflow tool budget reached") rows = queries.resources.list_by_run(run_id) matching = [r for r in rows if r.content_hash == content_hash] if not matching: @@ -202,7 +248,7 @@ async def get_resource_by_content_hash( matching.sort(key=lambda r: (r.created_at, r.id), reverse=True) return ResourceRef.from_row(matching[0]) - return Tool(function=get_resource_by_content_hash, takes_ctx=False) + return Tool(function=get_resource_by_content_hash, takes_ctx=True) # --------------------------------------------------------------------------- diff --git a/ergon_builtins/ergon_builtins/tools/subtask_lifecycle_toolkit.py b/ergon_builtins/ergon_builtins/tools/subtask_lifecycle_toolkit.py index 97793799..c2483b4a 100644 --- a/ergon_builtins/ergon_builtins/tools/subtask_lifecycle_toolkit.py +++ b/ergon_builtins/ergon_builtins/tools/subtask_lifecycle_toolkit.py @@ -10,8 +10,6 @@ from typing import Literal from uuid import UUID -from pydantic import BaseModel - from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.types import ( AssignedWorkerSlug, @@ -19,6 +17,8 @@ RunId, TaskSlug, ) +from ergon_core.core.runtime.services.task_inspection_dto import SubtaskInfo +from ergon_core.core.runtime.services.task_inspection_service import TaskInspectionService from ergon_core.core.runtime.services.task_management_dto import ( AddSubtaskCommand, CancelTaskCommand, @@ -27,9 +27,8 @@ RestartTaskCommand, SubtaskSpec, ) -from ergon_core.core.runtime.services.task_inspection_dto import SubtaskInfo from ergon_core.core.runtime.services.task_management_service import TaskManagementService -from ergon_core.core.runtime.services.task_inspection_service import TaskInspectionService +from pydantic import BaseModel from ergon_builtins.tools.bash_sandbox_tool import make_sandbox_bash_tool diff --git a/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py b/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py index 6195f414..9bd74e25 100644 --- a/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py +++ b/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py @@ -1,6 +1,6 @@ import asyncio from collections.abc import Awaitable, Callable -from typing import Protocol +from typing import Any, Protocol from uuid import UUID from ergon_cli.commands.workflow import ( @@ -11,8 +11,14 @@ from ergon_core.api.worker_context import WorkerContext from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.runtime.services.workflow_service import WorkflowService +from pydantic_ai import RunContext from sqlmodel import Session +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetExhaustedResult, +) + class WorkflowCommandExecutor(Protocol): def __call__( @@ -33,7 +39,8 @@ def make_workflow_cli_tool( execute_command: WorkflowCommandExecutor = execute_workflow_command, session_factory: Callable[[], Session] = get_session, service_factory: Callable[[], WorkflowService] = WorkflowService, -) -> Callable[[str], Awaitable[str]]: + budgeted: bool = False, +) -> Callable[..., Awaitable[Any]]: # slopcop: ignore[no-typing-any] """Build an agent-facing ``workflow(command)`` callable. The model supplies only the command string. Run, task, execution, and @@ -41,8 +48,7 @@ def make_workflow_cli_tool( run by passing alternate IDs. """ - async def workflow(command: str) -> str: - """Inspect workflow topology/resources or dry-run workflow management commands.""" + async def run_command(command: str) -> str: if worker_context.node_id is None: raise ValueError("workflow tool requires WorkerContext.node_id") @@ -66,4 +72,22 @@ async def workflow(command: str) -> str: return f"{output.stdout}\n\nstderr:\n{output.stderr}".strip() return output.stdout + if budgeted: + + async def workflow( + ctx: RunContext[AgentToolBudgetDeps], + command: str, + ) -> str | AgentToolBudgetExhaustedResult: + """Inspect workflow topology/resources or dry-run workflow management commands.""" + tool_budget = ctx.deps.tool_budget + if tool_budget.increment("workflow", "workflow") > tool_budget.max_workflow_tool_calls: + return tool_budget.exhausted_result("workflow tool budget reached") + return await run_command(command) + + return workflow + + async def workflow(command: str) -> str: + """Inspect workflow topology/resources or dry-run workflow management commands.""" + return await run_command(command) + return workflow diff --git a/ergon_builtins/pyproject.toml b/ergon_builtins/pyproject.toml index 44320b07..ff852f55 100644 --- a/ergon_builtins/pyproject.toml +++ b/ergon_builtins/pyproject.toml @@ -6,6 +6,7 @@ license = "Apache-2.0" requires-python = ">=3.13" dependencies = [ "ergon-core", + "logfire>=4.32.1", ] [project.optional-dependencies] diff --git a/ergon_cli/ergon_cli/commands/run.py b/ergon_cli/ergon_cli/commands/run.py index 3b19815e..803f7fa2 100644 --- a/ergon_cli/ergon_cli/commands/run.py +++ b/ergon_cli/ergon_cli/commands/run.py @@ -2,11 +2,13 @@ from argparse import Namespace from uuid import UUID -from sqlmodel import select -from ergon_cli.rendering import render_table + from ergon_core.core.persistence.shared.db import ensure_db, get_session -from ergon_core.core.runtime.services.run_service import cancel_run as do_cancel from ergon_core.core.persistence.telemetry.models import RunRecord +from ergon_core.core.runtime.services.run_service import cancel_run as do_cancel +from sqlmodel import select + +from ergon_cli.rendering import render_table def handle_run(args: Namespace) -> int: diff --git a/ergon_cli/ergon_cli/main.py b/ergon_cli/ergon_cli/main.py index 19d6905e..3324a4f7 100644 --- a/ergon_cli/ergon_cli/main.py +++ b/ergon_cli/ergon_cli/main.py @@ -11,8 +11,8 @@ from ergon_cli.commands.onboard import handle_onboard from ergon_cli.commands.run import handle_run from ergon_cli.commands.train import handle_train -from ergon_cli.commands.workflow import handle_workflow from ergon_cli.commands.worker import handle_worker +from ergon_cli.commands.workflow import handle_workflow def build_parser() -> argparse.ArgumentParser: diff --git a/ergon_cli/ergon_cli/onboarding/env_writer.py b/ergon_cli/ergon_cli/onboarding/env_writer.py index 61123657..8fea40b9 100644 --- a/ergon_cli/ergon_cli/onboarding/env_writer.py +++ b/ergon_cli/ergon_cli/onboarding/env_writer.py @@ -4,7 +4,6 @@ from ergon_cli.onboarding.profile import OnboardProfile - # Infra defaults that every .env should have — safe to set if missing. _INFRA_DEFAULTS: dict[str, str] = { "INNGEST_EVENT_KEY": "dev", diff --git a/ergon_cli/ergon_cli/onboarding/profile.py b/ergon_cli/ergon_cli/onboarding/profile.py index aee6c661..4f090acf 100644 --- a/ergon_cli/ergon_cli/onboarding/profile.py +++ b/ergon_cli/ergon_cli/onboarding/profile.py @@ -2,9 +2,8 @@ from enum import Enum -from pydantic import BaseModel, Field - from ergon_core.api.benchmark_deps import BenchmarkDeps +from pydantic import BaseModel, Field class LLMProvider(str, Enum): diff --git a/ergon_core/ergon_core/api/results.py b/ergon_core/ergon_core/api/results.py index c1e9cb6b..b8e99c77 100644 --- a/ergon_core/ergon_core/api/results.py +++ b/ergon_core/ergon_core/api/results.py @@ -1,8 +1,10 @@ """Public result types returned by workers, criteria, and evaluators.""" -from typing import Any +from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator + +from ergon_core.core.json_types import JsonObject class WorkerOutput(BaseModel): @@ -20,24 +22,72 @@ class WorkerOutput(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] +class CriterionScoreSpec(BaseModel): + """Criterion-local score range. + + This is the range an atomic criterion can emit. Aggregation penalties and + signed weights are evaluator/rubric concerns, not negative local scores. + """ + + model_config = {"frozen": True} + + min_score: float = 0.0 + max_score: float = 1.0 + + +class CriterionObservationMessage(BaseModel): + """One prompt-like message used while producing a criterion result.""" + + model_config = {"frozen": True} + + role: Literal["system", "user", "assistant", "tool"] + content: str + + +class CriterionObservation(BaseModel): + """Structured observation space for a criterion run.""" + + model_config = {"frozen": True} + + prompt_messages: list[CriterionObservationMessage] = Field(default_factory=list) + evidence_resource_ids: list[str] = Field(default_factory=list) + evidence_action_ids: list[str] = Field(default_factory=list) + output: JsonObject | None = None + model: str | None = None + details: JsonObject = Field(default_factory=dict) + + class CriterionResult(BaseModel): """Result of a single Criterion.evaluate() invocation.""" model_config = {"frozen": True} + slug: str name: str score: float passed: bool weight: float = 1.0 + max_score: float = 1.0 feedback: str | None = None model_reasoning: str | None = None skipped_reason: str | None = None evaluation_input: str | None = None evaluated_action_ids: list[str] = Field(default_factory=list) evaluated_resource_ids: list[str] = Field(default_factory=list) + observation: CriterionObservation | None = None error: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] + @model_validator(mode="before") + @classmethod + def _populate_slug_name(cls, data): + if isinstance(data, dict): + if "slug" not in data and "name" in data: + data["slug"] = data["name"] + if "name" not in data and "slug" in data: + data["name"] = data["slug"] + return data + class TaskEvaluationResult(BaseModel): """Aggregated evaluation result for one task across all criteria.""" diff --git a/ergon_core/ergon_core/core/api/app.py b/ergon_core/ergon_core/core/api/app.py index c251d1d4..ccd1b5cb 100644 --- a/ergon_core/ergon_core/core/api/app.py +++ b/ergon_core/ergon_core/core/api/app.py @@ -36,9 +36,8 @@ from ergon_core.core.providers.sandbox.manager import DefaultSandboxManager from ergon_core.core.rl.rollout_service import RolloutService from ergon_core.core.runtime.inngest_client import inngest_client -from ergon_core.core.settings import settings from ergon_core.core.runtime.inngest_registry import ALL_FUNCTIONS -from ergon_core.core.settings import Settings +from ergon_core.core.settings import Settings, settings from fastapi import FastAPI logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/api/runs.py b/ergon_core/ergon_core/core/api/runs.py index 849ff6b4..2696cbfd 100644 --- a/ergon_core/ergon_core/core/api/runs.py +++ b/ergon_core/ergon_core/core/api/runs.py @@ -220,6 +220,7 @@ def _task_keyed_evaluations( stage_num=cr.stage_num, stage_name=cr.stage_name, criterion_num=cr.criterion_num, + criterion_slug=cr.criterion_slug, criterion_type=cr.criterion_type, criterion_description=cr.criterion_description, criterion_name=cr.criterion_name, @@ -235,6 +236,7 @@ def _task_keyed_evaluations( skipped_reason=cr.skipped_reason, evaluated_action_ids=cr.evaluated_action_ids, evaluated_resource_ids=cr.evaluated_resource_ids, + observation=cr.observation.model_dump(mode="json") if cr.observation else None, error=cr.error, ) for i, cr in enumerate(summary.criterion_results) diff --git a/ergon_core/ergon_core/core/api/schemas.py b/ergon_core/ergon_core/core/api/schemas.py index 9e85f992..4e3f6be5 100644 --- a/ergon_core/ergon_core/core/api/schemas.py +++ b/ergon_core/ergon_core/core/api/schemas.py @@ -77,6 +77,7 @@ class RunEvaluationCriterionDto(CamelModel): stage_num: int stage_name: str criterion_num: int + criterion_slug: str criterion_type: str criterion_description: str criterion_name: str @@ -92,6 +93,7 @@ class RunEvaluationCriterionDto(CamelModel): skipped_reason: str | None = None evaluated_action_ids: list[str] = Field(default_factory=list) evaluated_resource_ids: list[str] = Field(default_factory=list) + observation: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] error: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] diff --git a/ergon_core/ergon_core/core/dashboard/emitter.py b/ergon_core/ergon_core/core/dashboard/emitter.py index 3318eaa2..cd91203d 100644 --- a/ergon_core/ergon_core/core/dashboard/emitter.py +++ b/ergon_core/ergon_core/core/dashboard/emitter.py @@ -26,8 +26,8 @@ from ergon_core.core.persistence.queries import queries from ergon_core.core.runtime.events.task_events import TaskCancelledEvent from ergon_core.core.runtime.inngest_client import inngest_client -from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service from ergon_core.core.runtime.services.cohort_schemas import CohortSummaryDto +from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service from ergon_core.core.runtime.services.cohort_stats_service import ( experiment_cohort_stats_service, ) @@ -50,8 +50,8 @@ DashboardTaskStatusChangedEvent, DashboardThreadMessageCreatedEvent, DashboardWorkflowCompletedEvent, - TaskTreeNode, DashboardWorkflowStartedEvent, + TaskTreeNode, ) _MUTATION_VALUE_ADAPTER: TypeAdapter[GraphMutationValue] = TypeAdapter(GraphMutationValue) diff --git a/ergon_core/ergon_core/core/dashboard/event_contracts.py b/ergon_core/ergon_core/core/dashboard/event_contracts.py index bd126b4f..2f7f0ccb 100644 --- a/ergon_core/ergon_core/core/dashboard/event_contracts.py +++ b/ergon_core/ergon_core/core/dashboard/event_contracts.py @@ -11,8 +11,6 @@ from typing import ClassVar from uuid import UUID -from pydantic import BaseModel - from ergon_core.core.api.schemas import ( RunCommunicationMessageDto, RunCommunicationThreadDto, @@ -26,6 +24,7 @@ from ergon_core.core.runtime.events.base import InngestEventContract from ergon_core.core.runtime.services.cohort_schemas import CohortSummaryDto from ergon_core.core.runtime.services.graph_dto import GraphMutationValue +from pydantic import BaseModel, Field # --------------------------------------------------------------------------- # Nested models used inside workflow.started @@ -234,14 +233,26 @@ class DashboardGraphMutationEvent(InngestEventContract): class DashboardContextEventEvent(InngestEventContract): name: ClassVar[str] = "dashboard/context.event" - id: UUID # RunContextEvent.id — dedup key on frontend + id: UUID = Field( + description="RunContextEvent.id used by the frontend as a stable deduplication key." + ) run_id: UUID task_execution_id: UUID - task_node_id: UUID # resolved from _execution_task_map at emit time + task_node_id: UUID = Field( + description=( + "Graph task node resolved from the task execution by the dashboard emitter at " + "event emission time." + ) + ) worker_binding_key: str sequence: int event_type: ContextEventType - payload: ContextEventPayload # serialised via model_dump(mode="json") + payload: ContextEventPayload = Field( + description=( + "Typed context event payload serialized with model_dump(mode='json') before " + "being sent through Inngest." + ) + ) created_at: datetime started_at: datetime | None completed_at: datetime | None diff --git a/ergon_core/ergon_core/core/persistence/context/event_payloads.py b/ergon_core/ergon_core/core/persistence/context/event_payloads.py index db19d6b1..57f4882d 100644 --- a/ergon_core/ergon_core/core/persistence/context/event_payloads.py +++ b/ergon_core/ergon_core/core/persistence/context/event_payloads.py @@ -7,7 +7,7 @@ from typing import Annotated, Any, Literal -from ergon_core.api.generation import TokenLogprob +from ergon_core.core.generation import TokenLogprob from pydantic import BaseModel, Field # Exported type alias — use everywhere event_type is stored as a string field. @@ -29,16 +29,37 @@ class SystemPromptPayload(BaseModel): class UserMessagePayload(BaseModel): event_type: Literal["user_message"] = "user_message" text: str - from_worker_key: str | None = None # set for agent-to-agent messages + from_worker_key: str | None = Field( + default=None, + description=( + "Worker binding key when this message was sent by another agent instead of " + "the external user." + ), + ) class AssistantTextPayload(BaseModel): event_type: Literal["assistant_text"] = "assistant_text" text: str - turn_id: str # links events from the same generation call - turn_token_ids: list[int] | None = None # set on FIRST model-output event of the turn only - turn_logprobs: list[TokenLogprob] | None = ( - None # set on FIRST model-output event of the turn only + turn_id: str = Field( + description=( + "Generation turn identifier that groups model-output events from the same " + "single synchronous agent run." + ) + ) + turn_token_ids: list[int] | None = Field( + default=None, + description=( + "Token ids for the generation turn. Present only on the first model-output " + "event so sibling events can share the turn-level token stream." + ), + ) + turn_logprobs: list[TokenLogprob] | None = Field( + default=None, + description=( + "Token logprobs for the generation turn. Present only on the first " + "model-output event so sibling events can share the turn-level logprob stream." + ), ) @@ -47,17 +68,39 @@ class ToolCallPayload(BaseModel): tool_call_id: str tool_name: str args: dict[str, Any] # slopcop: ignore[no-typing-any] - turn_id: str # links events from the same generation call - turn_token_ids: list[int] | None = None # None if another event in this turn holds them - turn_logprobs: list[TokenLogprob] | None = None # None if another event in this turn holds them + turn_id: str = Field( + description=( + "Generation turn identifier that groups this tool call with other events " + "emitted by the same single synchronous agent run." + ) + ) + turn_token_ids: list[int] | None = Field( + default=None, + description=( + "Token ids for the generation turn, omitted when another event in this turn " + "carries the shared token stream." + ), + ) + turn_logprobs: list[TokenLogprob] | None = Field( + default=None, + description=( + "Token logprobs for the generation turn, omitted when another event in this " + "turn carries the shared logprob stream." + ), + ) class ToolResultPayload(BaseModel): event_type: Literal["tool_result"] = "tool_result" - tool_call_id: str # links back to the ToolCallPayload with the same id + tool_call_id: str = Field( + description="Identifier linking this result back to the ToolCallPayload it answers." + ) tool_name: str - result: ( - Any # slopcop: ignore[no-typing-any] # intentionally open — any JSON-serialisable value + result: Any = Field( # slopcop: ignore[no-typing-any] + description=( + "Open JSON-serializable value returned by the tool call; intentionally accepts " + "any persisted result shape." + ) ) is_error: bool = False @@ -65,10 +108,25 @@ class ToolResultPayload(BaseModel): class ThinkingPayload(BaseModel): event_type: Literal["thinking"] = "thinking" text: str - turn_id: str # links events from the same generation call - turn_token_ids: list[int] | None = None # set on FIRST model-output event of the turn only - turn_logprobs: list[TokenLogprob] | None = ( - None # set on FIRST model-output event of the turn only + turn_id: str = Field( + description=( + "Generation turn identifier that groups thinking text with other events from " + "the same single synchronous agent run." + ) + ) + turn_token_ids: list[int] | None = Field( + default=None, + description=( + "Token ids for the generation turn. Present only on the first model-output " + "event so sibling events can share the turn-level token stream." + ), + ) + turn_logprobs: list[TokenLogprob] | None = Field( + default=None, + description=( + "Token logprobs for the generation turn. Present only on the first " + "model-output event so sibling events can share the turn-level logprob stream." + ), ) diff --git a/ergon_core/ergon_core/core/persistence/context/models.py b/ergon_core/ergon_core/core/persistence/context/models.py index 0097e807..548e10f5 100644 --- a/ergon_core/ergon_core/core/persistence/context/models.py +++ b/ergon_core/ergon_core/core/persistence/context/models.py @@ -35,10 +35,17 @@ class RunContextEvent(SQLModel, table=True): task_execution_id: UUID = Field(foreign_key="run_task_executions.id", index=True) worker_binding_key: str = Field(index=True) sequence: int - event_type: str = Field(index=True) # ContextEventType Literal — str for SQLModel compat - payload: dict[str, Any] = Field(sa_column=Column(JSON)) # slopcop: ignore[no-typing-any] - # Note: Uses JSON (not JSONB) for SQLite test compatibility. - # The migration uses JSONB for PostgreSQL production. + event_type: str = Field( + index=True, + description="ContextEventType literal stored as a string for SQLModel compatibility.", + ) + payload: dict[str, Any] = Field( # slopcop: ignore[no-typing-any] + sa_column=Column(JSON), + description=( + "Typed ContextEventPayload persisted as JSON. The SQLModel column uses JSON " + "for SQLite test compatibility; the PostgreSQL migration uses JSONB." + ), + ) started_at: datetime | None = Field(default=None, sa_type=TZDateTime) completed_at: datetime | None = Field(default=None, sa_type=TZDateTime) created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) diff --git a/ergon_core/ergon_core/core/persistence/context/repository.py b/ergon_core/ergon_core/core/persistence/context/repository.py index 83fec7e5..bd676f35 100644 --- a/ergon_core/ergon_core/core/persistence/context/repository.py +++ b/ergon_core/ergon_core/core/persistence/context/repository.py @@ -10,7 +10,7 @@ from datetime import datetime from uuid import UUID, uuid4 -from ergon_core.api.generation import ( +from ergon_core.core.generation import ( GenerationTurn, SystemPromptPart, TextPart, diff --git a/ergon_core/ergon_core/core/persistence/definitions/models.py b/ergon_core/ergon_core/core/persistence/definitions/models.py index 62576fa6..cc89bab3 100644 --- a/ergon_core/ergon_core/core/persistence/definitions/models.py +++ b/ergon_core/ergon_core/core/persistence/definitions/models.py @@ -9,7 +9,7 @@ from typing import TypeVar from uuid import UUID, uuid4 -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.utils import utcnow as _utcnow from pydantic import BaseModel, model_validator from sqlalchemy import JSON, Column, DateTime diff --git a/ergon_core/ergon_core/core/persistence/graph/models.py b/ergon_core/ergon_core/core/persistence/graph/models.py index 9e949843..8c123094 100644 --- a/ergon_core/ergon_core/core/persistence/graph/models.py +++ b/ergon_core/ergon_core/core/persistence/graph/models.py @@ -14,7 +14,7 @@ from typing import Literal from uuid import UUID, uuid4 -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.utils import utcnow as _utcnow from pydantic import model_validator from sqlalchemy import JSON, Column, DateTime, Index @@ -51,39 +51,54 @@ class RunGraphNode(SQLModel, table=True): default=None, foreign_key="experiment_definition_tasks.id", ) - # Identifies which benchmark instance this node belongs to (e.g. - # which dataset row or environment variant). Maps to - # ExperimentDefinitionInstance.instance_key. - instance_key: str - - # Identifies the task slot in the experiment template (e.g. - # 'research-av-safety') OR the caller-chosen slug for a - # dynamically-spawned subtask. Required at creation, persisted verbatim. - task_slug: str = Field(index=True) + instance_key: str = Field( + description=( + "Benchmark instance identifier for this node, such as a dataset row or " + "environment variant; maps to ExperimentDefinitionInstance.instance_key." + ) + ) + task_slug: str = Field( + index=True, + description=( + "Task slot in the experiment template, or the caller-chosen slug for a " + "dynamically spawned subtask. Required at creation and persisted verbatim." + ), + ) description: str - # Free-form string, not an enum. The experiment layer owns domain-specific - # status values (e.g. "proposed", "negotiating", "completed") so different - # experiments can define different lifecycles without core schema changes. - status: str = Field(index=True) + status: str = Field( + index=True, + description=( + "Free-form node status owned by the experiment layer so different " + "experiments can define lifecycles without core schema changes." + ), + ) - # WORKERS-registry slug, e.g. "researchrubrics-researcher", "canonical-smoke". - assigned_worker_slug: str | None = None + assigned_worker_slug: str | None = Field( + default=None, + description=( + "WORKERS registry slug assigned to execute this node, for example " + "'researchrubrics-researcher' or 'canonical-smoke'." + ), + ) - # Containment: self-referential FK to the spawning node. - # NULL for definition-seeded roots; set for every dynamic subtask. - # Stored (not derived) so a single SELECT on run_graph_nodes gives - # a fully legible hierarchy without joins or edge traversal. parent_node_id: UUID | None = Field( default=None, foreign_key="run_graph_nodes.id", index=True, + description=( + "Self-referential containment parent. Null for definition-seeded roots and set " + "for dynamic subtasks so hierarchy can be read without joins or edge traversal." + ), ) - # Depth in the containment tree. 0 for roots, parent.level + 1 - # for dynamic subtasks. Stored for debuggability and to avoid - # N+1 level computation at query/rendering time. - level: int = Field(default=0) + level: int = Field( + default=0, + description=( + "Depth in the containment tree: 0 for roots and parent.level + 1 for dynamic " + "subtasks. Stored for debugging and to avoid N+1 level computation." + ), + ) created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) updated_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) @@ -143,7 +158,12 @@ class RunGraphAnnotation(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) run_id: UUID = Field(foreign_key="runs.id", index=True) - target_type: str # GraphTargetType ("node" | "edge") — str for SQLModel compat + target_type: str = Field( + description=( + "GraphTargetType literal ('node' or 'edge') stored as a string for SQLModel " + "compatibility." + ) + ) target_id: UUID namespace: str sequence: int = Field(index=True) @@ -176,8 +196,16 @@ class RunGraphMutation(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) run_id: UUID = Field(foreign_key="runs.id", index=True) sequence: int = Field(index=True) - mutation_type: str = Field(index=True) # MutationType Literal — str for SQLModel compat - target_type: str # GraphTargetType ("node" | "edge") — str for SQLModel compat + mutation_type: str = Field( + index=True, + description="MutationType literal stored as a string for SQLModel compatibility.", + ) + target_type: str = Field( + description=( + "GraphTargetType literal ('node' or 'edge') stored as a string for SQLModel " + "compatibility." + ) + ) target_id: UUID = Field(index=True) actor: str old_value: dict | None = Field(default=None, sa_column=Column(JSON)) diff --git a/ergon_core/ergon_core/core/persistence/queries.py b/ergon_core/ergon_core/core/persistence/queries.py index 7cd99f73..0b302ab0 100644 --- a/ergon_core/ergon_core/core/persistence/queries.py +++ b/ergon_core/ergon_core/core/persistence/queries.py @@ -9,7 +9,7 @@ from typing import Any, Generic, Type, TypeVar from uuid import UUID -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.definitions.models import ( ExperimentDefinition, ExperimentDefinitionEvaluator, diff --git a/ergon_core/ergon_core/core/persistence/saved_specs/models.py b/ergon_core/ergon_core/core/persistence/saved_specs/models.py index 0891a7f7..f1ee2c28 100644 --- a/ergon_core/ergon_core/core/persistence/saved_specs/models.py +++ b/ergon_core/ergon_core/core/persistence/saved_specs/models.py @@ -5,7 +5,7 @@ from datetime import datetime from uuid import UUID, uuid4 -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.utils import utcnow as _utcnow from pydantic import model_validator from sqlalchemy import JSON, Column diff --git a/ergon_core/ergon_core/core/persistence/telemetry/repositories.py b/ergon_core/ergon_core/core/persistence/telemetry/repositories.py index bc2a90f1..3c969e47 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/repositories.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/repositories.py @@ -2,7 +2,7 @@ from uuid import UUID -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.shared.ids import new_id from ergon_core.core.persistence.telemetry.models import ( RunRecord, diff --git a/ergon_core/ergon_core/core/providers/generation/openrouter_budget.py b/ergon_core/ergon_core/core/providers/generation/openrouter_budget.py index 5e9b2fb8..35dcd3b5 100644 --- a/ergon_core/ergon_core/core/providers/generation/openrouter_budget.py +++ b/ergon_core/ergon_core/core/providers/generation/openrouter_budget.py @@ -9,7 +9,6 @@ """ import httpx - from ergon_core.core.settings import settings diff --git a/ergon_core/ergon_core/core/providers/judges/__init__.py b/ergon_core/ergon_core/core/providers/judges/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ergon_core/ergon_core/core/runtime/errors/error_payload.py b/ergon_core/ergon_core/core/runtime/errors/error_payload.py index 55833feb..e75806d3 100644 --- a/ergon_core/ergon_core/core/runtime/errors/error_payload.py +++ b/ergon_core/ergon_core/core/runtime/errors/error_payload.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from typing import Any -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from pydantic import BaseModel, Field diff --git a/ergon_core/ergon_core/core/runtime/evaluation/evaluation_schemas.py b/ergon_core/ergon_core/core/runtime/evaluation/evaluation_schemas.py index 29c52ea9..6c7c65f6 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/evaluation_schemas.py +++ b/ergon_core/ergon_core/core/runtime/evaluation/evaluation_schemas.py @@ -3,7 +3,7 @@ from uuid import UUID from ergon_core.api.criterion import Criterion -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from pydantic import BaseModel, ConfigDict, Field __all__ = [ diff --git a/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py b/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py index d0e9fdd4..85888538 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py +++ b/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py @@ -79,7 +79,7 @@ async def run_criterion() -> CriterionResult: sandbox_manager=self.sandbox_manager, options=CriterionRuntimeOptions( run_id=task_context.run_id, - task_id=self.task_id, + task_id=self.execution_id, # Per RFC ``sandbox-lifetime-covers-criteria``: pass # the task's sandbox_id so ensure_sandbox prefers # ``manager.reconnect(sandbox_id)`` over constructing diff --git a/ergon_core/ergon_core/core/runtime/execution/propagation.py b/ergon_core/ergon_core/core/runtime/execution/propagation.py index 8f26a125..efa4aa02 100644 --- a/ergon_core/ergon_core/core/runtime/execution/propagation.py +++ b/ergon_core/ergon_core/core/runtime/execution/propagation.py @@ -9,7 +9,7 @@ from uuid import UUID -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.definitions.models import ( ExperimentDefinitionTask, ExperimentDefinitionTaskDependency, diff --git a/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py b/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py index f368a5f4..3daf00a8 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py +++ b/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py @@ -15,7 +15,6 @@ from uuid import UUID import inngest - from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.runtime.events.task_events import ( CancelCause, diff --git a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py b/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py index 683416e3..649438c8 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py +++ b/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py @@ -9,9 +9,8 @@ import logging import inngest - -from ergon_core.api.json_types import JsonObject from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.runtime.events.task_events import TaskCancelledEvent from ergon_core.core.runtime.inngest_client import RUN_CANCEL, inngest_client diff --git a/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py b/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py index ce38b973..363afed7 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py +++ b/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py @@ -4,14 +4,13 @@ runs all criteria, aggregates results, persists RunTaskEvaluation. """ -from datetime import UTC, datetime import logging +from datetime import UTC, datetime import inngest -from pydantic import BaseModel from ergon_builtins.registry import BENCHMARKS, EVALUATORS, SANDBOX_MANAGERS -from ergon_core.core.dashboard.emitter import dashboard_emitter from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload +from ergon_core.core.dashboard.emitter import dashboard_emitter from ergon_core.core.persistence.queries import queries from ergon_core.core.providers.sandbox.manager import DefaultSandboxManager from ergon_core.core.runtime.errors import ContractViolationError, RegistryLookupError @@ -21,12 +20,12 @@ from ergon_core.core.runtime.services.child_function_payloads import ( EvaluateTaskRunRequest, ) -from ergon_core.core.runtime.services.inngest_function_results import ( - EvaluateTaskRunResult, -) from ergon_core.core.runtime.services.evaluation_persistence_service import ( EvaluationPersistenceService, ) +from ergon_core.core.runtime.services.inngest_function_results import ( + EvaluateTaskRunResult, +) from ergon_core.core.runtime.services.rubric_evaluation_service import ( RubricEvaluationService, ) @@ -35,6 +34,7 @@ evaluation_task_context, get_trace_sink, ) +from pydantic import BaseModel logger = logging.getLogger(__name__) evaluation_persistence = EvaluationPersistenceService() diff --git a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py b/ergon_core/ergon_core/core/runtime/inngest/execute_task.py index 78231a6e..a5add7fc 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py +++ b/ergon_core/ergon_core/core/runtime/inngest/execute_task.py @@ -15,6 +15,9 @@ TaskFailedEvent, TaskReadyEvent, ) +from ergon_core.core.runtime.inngest.persist_outputs import persist_outputs_fn +from ergon_core.core.runtime.inngest.sandbox_setup import sandbox_setup_fn +from ergon_core.core.runtime.inngest.worker_execute import worker_execute_fn from ergon_core.core.runtime.inngest_client import RUN_CANCEL, TASK_CANCEL, inngest_client from ergon_core.core.runtime.services.child_function_payloads import ( PersistOutputsRequest, @@ -34,9 +37,6 @@ PrepareTaskExecutionCommand, ) from ergon_core.core.runtime.services.task_execution_service import TaskExecutionService -from ergon_core.core.runtime.inngest.persist_outputs import persist_outputs_fn -from ergon_core.core.runtime.inngest.sandbox_setup import sandbox_setup_fn -from ergon_core.core.runtime.inngest.worker_execute import worker_execute_fn from ergon_core.core.runtime.tracing import ( CompletedSpan, get_trace_sink, diff --git a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py b/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py index 87b1ae89..518c001b 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py +++ b/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py @@ -31,7 +31,6 @@ task_propagate_context, ) - logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py index 95133099..b29540f0 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py +++ b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py @@ -10,13 +10,12 @@ from datetime import UTC, datetime import inngest -from pydantic import BaseModel from ergon_builtins.registry import BENCHMARKS, WORKERS -from ergon_core.api.generation import GenerationTurn from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload from ergon_core.api.worker_context import WorkerContext from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.generation import GenerationTurn from ergon_core.core.persistence.context.repository import ContextEventRepository from ergon_core.core.persistence.queries import queries from ergon_core.core.persistence.shared.db import get_session @@ -30,6 +29,7 @@ get_trace_sink, worker_execute_context, ) +from pydantic import BaseModel logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/runtime/inngest_registry.py b/ergon_core/ergon_core/core/runtime/inngest_registry.py index a3033010..0e9ee1aa 100644 --- a/ergon_core/ergon_core/core/runtime/inngest_registry.py +++ b/ergon_core/ergon_core/core/runtime/inngest_registry.py @@ -8,8 +8,8 @@ block_descendants_on_failed_fn, cancel_orphans_on_cancelled_fn, ) -from ergon_core.core.runtime.inngest.cleanup_cancelled_task import cleanup_cancelled_task_fn from ergon_core.core.runtime.inngest.check_evaluators import check_and_run_evaluators +from ergon_core.core.runtime.inngest.cleanup_cancelled_task import cleanup_cancelled_task_fn from ergon_core.core.runtime.inngest.complete_workflow import complete_workflow_fn from ergon_core.core.runtime.inngest.evaluate_task_run import evaluate_task_run from ergon_core.core.runtime.inngest.execute_task import execute_task_fn diff --git a/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py b/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py index c4223397..42a2c25e 100644 --- a/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py +++ b/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py @@ -4,7 +4,7 @@ from typing import Literal from uuid import UUID -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.telemetry.models import ExperimentCohortStatus from pydantic import BaseModel, Field diff --git a/ergon_core/ergon_core/core/runtime/services/cohort_service.py b/ergon_core/ergon_core/core/runtime/services/cohort_service.py index c00499f0..e12b0cae 100644 --- a/ergon_core/ergon_core/core/runtime/services/cohort_service.py +++ b/ergon_core/ergon_core/core/runtime/services/cohort_service.py @@ -3,6 +3,7 @@ from collections import Counter, defaultdict from uuid import UUID +from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.evaluation_summary import EvaluationSummary from ergon_core.core.persistence.telemetry.models import ( @@ -12,7 +13,6 @@ RunRecord, RunTaskEvaluation, ) -from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.runtime.services.cohort_schemas import ( CohortDetailDto, CohortRubricStatusSummaryDto, @@ -139,7 +139,9 @@ def get_detail(self, cohort_id: UUID) -> CohortDetailDto | None: evaluations_by_run: defaultdict[UUID, list[EvaluationSummary]] = defaultdict(list) if runs: evaluations = session.exec( - select(RunTaskEvaluation).where(RunTaskEvaluation.run_id.in_([run.id for run in runs])) + select(RunTaskEvaluation).where( + RunTaskEvaluation.run_id.in_([run.id for run in runs]) + ) ).all() for evaluation in evaluations: evaluations_by_run[evaluation.run_id].append(evaluation.parsed_summary()) diff --git a/ergon_core/ergon_core/core/runtime/services/evaluation_dto.py b/ergon_core/ergon_core/core/runtime/services/evaluation_dto.py index 022043f2..d5b0ff9a 100644 --- a/ergon_core/ergon_core/core/runtime/services/evaluation_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/evaluation_dto.py @@ -2,7 +2,7 @@ from uuid import UUID -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from pydantic import BaseModel, Field diff --git a/ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py b/ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py index c4f052c6..d8101f48 100644 --- a/ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py +++ b/ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py @@ -128,6 +128,12 @@ def _criterion_status(*, passed: bool, error: dict | None, skipped_reason: str | return "passed" if passed else "failed" +def _summary_max_score(result, specs) -> float: + if result.metadata.get("score_scale") == "normalized_0_1": + return 1.0 + return sum(s.max_score for s in specs) if specs else 1.0 + + def build_evaluation_summary( service_result: EvaluationServiceResult, evaluation_input: str | None, @@ -137,21 +143,22 @@ def build_evaluation_summary( specs = service_result.specs spec_by_idx = {s.criterion_idx: s for s in specs} - max_score_total = sum(s.max_score for s in specs) if specs else 1.0 + max_score_total = _summary_max_score(result, specs) entries: list[CriterionResultEntry] = [] for i, cr in enumerate(result.criterion_results): spec = spec_by_idx.get(i) if spec is None: raise ContractViolationError( - f"Criterion result at index {i} ({cr.name!r}) has no matching " + f"Criterion result at index {i} ({cr.slug!r}) has no matching " f"CriterionSpec - specs and results are out of sync", ) entries.append( CriterionResultEntry( + criterion_slug=cr.slug, criterion_name=cr.name, criterion_type=spec.criterion.type_slug, - criterion_description=spec.criterion.name, + criterion_description=spec.criterion.description, stage_num=spec.stage_idx, stage_name=spec.stage_name, criterion_num=spec.criterion_idx, @@ -171,12 +178,12 @@ def build_evaluation_summary( evaluation_input=cr.evaluation_input or evaluation_input, evaluated_action_ids=cr.evaluated_action_ids, evaluated_resource_ids=cr.evaluated_resource_ids, + observation=cr.observation, error=cr.error, ) ) total_score = result.score - normalized = total_score / max_score_total if max_score_total > 0 else 0.0 stage_names = {s.stage_name for s in specs} stages_passed = sum( @@ -188,9 +195,10 @@ def build_evaluation_summary( return EvaluationSummary( evaluator_name=result.evaluator_name, max_score=max_score_total, - normalized_score=normalized, + normalized_score=total_score, stages_evaluated=len(stage_names), stages_passed=stages_passed, + metadata=result.metadata, criterion_results=entries, ) @@ -210,6 +218,7 @@ def build_dashboard_evaluation_dto( stage_num=cr.stage_num, stage_name=cr.stage_name, criterion_num=cr.criterion_num, + criterion_slug=cr.criterion_slug, criterion_type=cr.criterion_type, criterion_description=cr.criterion_description, criterion_name=cr.criterion_name, @@ -225,6 +234,7 @@ def build_dashboard_evaluation_dto( skipped_reason=cr.skipped_reason, evaluated_action_ids=cr.evaluated_action_ids, evaluated_resource_ids=cr.evaluated_resource_ids, + observation=cr.observation.model_dump(mode="json") if cr.observation else None, error=cr.error, ) for i, cr in enumerate(summary.criterion_results) diff --git a/ergon_core/ergon_core/core/runtime/services/experiment_persistence_service.py b/ergon_core/ergon_core/core/runtime/services/experiment_persistence_service.py index c341ed49..cd49274a 100644 --- a/ergon_core/ergon_core/core/runtime/services/experiment_persistence_service.py +++ b/ergon_core/ergon_core/core/runtime/services/experiment_persistence_service.py @@ -9,8 +9,7 @@ from ergon_core.api.evaluator import Rubric from ergon_core.api.handles import PersistedExperimentDefinition -from ergon_core.api.json_types import JsonObject -from sqlalchemy.exc import SQLAlchemyError +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.definitions.models import ( ExperimentDefinition, ExperimentDefinitionEvaluator, @@ -23,6 +22,7 @@ ) from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.utils import utcnow +from sqlalchemy.exc import SQLAlchemyError if TYPE_CHECKING: from ergon_core.api.experiment import Experiment diff --git a/ergon_core/ergon_core/core/runtime/services/graph_dto.py b/ergon_core/ergon_core/core/runtime/services/graph_dto.py index 6c4b1a66..80130bdb 100644 --- a/ergon_core/ergon_core/core/runtime/services/graph_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/graph_dto.py @@ -11,7 +11,7 @@ from typing import Annotated, Literal from uuid import UUID -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.graph.models import GraphTargetType, MutationType from ergon_core.core.persistence.shared.types import ( DefinitionId, @@ -45,7 +45,12 @@ class GraphNodeDto(BaseModel): instance_key: str task_slug: str description: str - status: str # not NodeStatus — DB allows domain-specific statuses (§4.7 in status_conventions) + status: str = Field( + description=( + "Domain-specific node lifecycle status stored as a string because the database " + "allows experiment-specific statuses; see status_conventions." + ) + ) assigned_worker_slug: str | None parent_node_id: NodeId | None level: int @@ -59,16 +64,26 @@ class GraphEdgeDto(BaseModel): definition_dependency_id: DefinitionId | None source_node_id: NodeId target_node_id: NodeId - status: str # not EdgeStatus — DB allows domain-specific statuses + status: str = Field( + description=( + "Domain-specific edge lifecycle status stored as a string because the database " + "allows experiment-specific dependency statuses." + ) + ) class GraphAnnotationDto(BaseModel): model_config = {"frozen": True} - id: UUID # annotation's own id + id: UUID = Field(description="Identifier of the annotation row itself.") run_id: RunId target_type: GraphTargetType - target_id: UUID # polymorphic: NodeId or EdgeId depending on target_type + target_id: UUID = Field( + description=( + "Polymorphic graph target identifier. Interpreted as a NodeId or EdgeId based " + "on target_type." + ) + ) namespace: str sequence: int payload: JsonObject @@ -77,12 +92,17 @@ class GraphAnnotationDto(BaseModel): class GraphMutationDto(BaseModel): model_config = {"frozen": True} - id: UUID # mutation's own id — not a node/edge/run id + id: UUID = Field(description="Identifier of the mutation row itself, not a graph target id.") run_id: RunId sequence: int mutation_type: MutationType target_type: GraphTargetType - target_id: UUID # polymorphic: could be NodeId, EdgeId, or annotation id + target_id: UUID = Field( + description=( + "Polymorphic mutation target identifier. Interpreted as a NodeId, EdgeId, or " + "annotation id based on target_type and mutation_type." + ) + ) actor: str old_value: "GraphMutationValue | None" new_value: "GraphMutationValue" diff --git a/ergon_core/ergon_core/core/runtime/services/graph_repository.py b/ergon_core/ergon_core/core/runtime/services/graph_repository.py index 01ce6591..1009d2ae 100644 --- a/ergon_core/ergon_core/core/runtime/services/graph_repository.py +++ b/ergon_core/ergon_core/core/runtime/services/graph_repository.py @@ -15,8 +15,6 @@ from typing import Literal from uuid import UUID, uuid4 -from pydantic import BaseModel - from ergon_core.core.persistence.definitions.models import ( ExperimentDefinitionInstance, ExperimentDefinitionTask, @@ -55,6 +53,7 @@ WorkflowGraphDto, ) from ergon_core.core.utils import utcnow +from pydantic import BaseModel from sqlmodel import Session, col, select logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py b/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py index f7b5c254..48861f73 100644 --- a/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py +++ b/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py @@ -6,7 +6,7 @@ from typing import Literal from uuid import UUID -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from pydantic import BaseModel, Field diff --git a/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py b/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py index 5f514809..438bd25a 100644 --- a/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py @@ -18,7 +18,7 @@ class StrEnum(str, Enum): from uuid import UUID -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from pydantic import BaseModel, Field diff --git a/ergon_core/ergon_core/core/runtime/services/run_service.py b/ergon_core/ergon_core/core/runtime/services/run_service.py index ee7e947c..0d7679bb 100644 --- a/ergon_core/ergon_core/core/runtime/services/run_service.py +++ b/ergon_core/ergon_core/core/runtime/services/run_service.py @@ -6,7 +6,7 @@ import inngest from ergon_core.api.handles import ExperimentRunHandle, PersistedExperimentDefinition -from ergon_core.api.json_types import JsonObject +from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TERMINAL_RUN_STATUSES, RunStatus from ergon_core.core.persistence.telemetry.models import RunRecord diff --git a/ergon_core/ergon_core/core/runtime/services/subtask_blocking_service.py b/ergon_core/ergon_core/core/runtime/services/subtask_blocking_service.py index 801304e6..1fd00eef 100644 --- a/ergon_core/ergon_core/core/runtime/services/subtask_blocking_service.py +++ b/ergon_core/ergon_core/core/runtime/services/subtask_blocking_service.py @@ -11,12 +11,11 @@ from collections import deque from uuid import UUID -from sqlmodel import Session, select - from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.graph.status_conventions import BLOCKED, RUNNING, TERMINAL_STATUSES from ergon_core.core.runtime.services.graph_dto import MutationMeta from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository +from sqlmodel import Session, select class SubtaskBlockingService: diff --git a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_dto.py b/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_dto.py index 515e5080..7a59c1bc 100644 --- a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_dto.py @@ -1,9 +1,8 @@ """DTOs for SubtaskCancellationService.""" -from pydantic import BaseModel - from ergon_core.core.persistence.shared.types import NodeId from ergon_core.core.runtime.events.task_events import TaskCancelledEvent +from pydantic import BaseModel class CancelOrphansResult(BaseModel): diff --git a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py b/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py index ab14d933..aae83597 100644 --- a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py +++ b/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py @@ -12,8 +12,6 @@ from typing import Literal from uuid import UUID -from sqlmodel import Session, select - from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.graph.status_conventions import ( CANCELLED, @@ -24,6 +22,7 @@ from ergon_core.core.runtime.services.graph_dto import MutationMeta from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository from ergon_core.core.runtime.services.subtask_cancellation_dto import CancelOrphansResult +from sqlmodel import Session, select logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/runtime/services/task_cleanup_dto.py b/ergon_core/ergon_core/core/runtime/services/task_cleanup_dto.py index d302a379..70285f81 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_cleanup_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/task_cleanup_dto.py @@ -2,9 +2,8 @@ from uuid import UUID -from pydantic import BaseModel - from ergon_core.core.persistence.shared.types import NodeId, RunId +from pydantic import BaseModel class CleanupResult(BaseModel): diff --git a/ergon_core/ergon_core/core/runtime/services/task_cleanup_service.py b/ergon_core/ergon_core/core/runtime/services/task_cleanup_service.py index 6efccd59..ff96add3 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_cleanup_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_cleanup_service.py @@ -12,11 +12,10 @@ import logging from uuid import UUID -from sqlmodel import Session, select - from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunTaskExecution from ergon_core.core.runtime.services.task_cleanup_dto import CleanupResult +from sqlmodel import Session, select logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py b/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py index a46ca623..68e25940 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py @@ -2,9 +2,8 @@ from typing import Literal -from pydantic import BaseModel - from ergon_core.core.persistence.shared.types import NodeId +from pydantic import BaseModel SubtaskStatus = Literal[ "pending", diff --git a/ergon_core/ergon_core/core/runtime/services/task_inspection_service.py b/ergon_core/ergon_core/core/runtime/services/task_inspection_service.py index 83694518..76f5186c 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_inspection_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_inspection_service.py @@ -7,12 +7,11 @@ import logging from uuid import UUID -from sqlmodel import Session, select - from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode from ergon_core.core.persistence.graph.status_conventions import COMPLETED, FAILED from ergon_core.core.persistence.telemetry.models import RunTaskExecution from ergon_core.core.runtime.services.task_inspection_dto import SubtaskInfo +from sqlmodel import Session, select logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/runtime/services/task_management_dto.py b/ergon_core/ergon_core/core/runtime/services/task_management_dto.py index af73e53a..8a30d9aa 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_management_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/task_management_dto.py @@ -4,15 +4,13 @@ swaps at the call boundary. """ -from pydantic import BaseModel, Field - from ergon_core.core.persistence.shared.types import ( AssignedWorkerSlug, NodeId, RunId, TaskSlug, ) - +from pydantic import BaseModel, Field # ── add_subtask ──────────────────────────────────────────────────────────── diff --git a/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py b/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py index 07120b5b..c913dd9b 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py @@ -10,8 +10,8 @@ is_workflow_failed_v2, on_task_completed_or_failed, ) -from ergon_core.core.runtime.services.graph_lookup import GraphNodeLookup from ergon_core.core.runtime.services.graph_dto import MutationMeta +from ergon_core.core.runtime.services.graph_lookup import GraphNodeLookup from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository from ergon_core.core.runtime.services.orchestration_dto import ( PropagateTaskCompletionCommand, diff --git a/ergon_core/ergon_core/core/runtime/tracing.py b/ergon_core/ergon_core/core/runtime/tracing.py index 76da1ec2..927e7d60 100644 --- a/ergon_core/ergon_core/core/runtime/tracing.py +++ b/ergon_core/ergon_core/core/runtime/tracing.py @@ -52,6 +52,7 @@ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter except ImportError: OTLPSpanExporter = None # type: ignore[assignment,misc] +from ergon_core.core.json_types import JsonObject, JsonValue from ergon_core.core.settings import settings from opentelemetry.trace import ( NonRecordingSpan, @@ -64,8 +65,6 @@ from opentelemetry.trace.span import TraceState from pydantic import BaseModel, Field -from ergon_core.api.json_types import JsonObject, JsonValue - TRACE_FLAGS_SAMPLED = 0x01 _MAX_TRACE_ID = (1 << 128) - 1 _MAX_SPAN_ID = (1 << 64) - 1 diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py b/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py index 07ace4a9..24e84df5 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py @@ -16,7 +16,6 @@ import os from ergon_builtins.registry import BENCHMARKS, EVALUATORS, SANDBOX_MANAGERS, WORKERS - from ergon_core.test_support.smoke_fixtures.benchmarks import ( MiniF2FSmokeBenchmark, ResearchRubricsSmokeBenchmark, diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py b/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py index 35bd1eba..53e82a35 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py @@ -10,16 +10,14 @@ from collections.abc import Mapping, Sequence from typing import ClassVar -from pydantic import BaseModel - -from ergon_core.api.benchmark import Benchmark -from ergon_core.api.benchmark_deps import BenchmarkDeps -from ergon_core.api.json_types import JsonObject -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload - from ergon_builtins.benchmarks.minif2f.task_schemas import MiniF2FTaskPayload from ergon_builtins.benchmarks.researchrubrics.task_schemas import ResearchRubricsTaskPayload from ergon_builtins.benchmarks.swebench_verified.task_schemas import SWEBenchTaskPayload +from ergon_core.api.benchmark import Benchmark +from ergon_core.api.benchmark_deps import BenchmarkDeps +from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload +from ergon_core.core.json_types import JsonObject +from pydantic import BaseModel class _SingleTaskSmokeBenchmark(Benchmark): diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/minif2f_smoke.py b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/minif2f_smoke.py index fe3bf643..65e90e80 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/minif2f_smoke.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/minif2f_smoke.py @@ -15,9 +15,8 @@ from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution -from sqlmodel import col, desc, select - from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from sqlmodel import col, desc, select HEALTH_THEOREM = """\ theorem health_check : True := trivial diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/researchrubrics_smoke.py b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/researchrubrics_smoke.py index 84643772..9c0f1091 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/researchrubrics_smoke.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/researchrubrics_smoke.py @@ -23,9 +23,8 @@ from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution -from sqlmodel import col, desc, select - from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from sqlmodel import col, desc, select class ResearchRubricsSmokeCriterion(SmokeCriterionBase): diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py index d8708dca..4cb0aea5 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py @@ -18,7 +18,6 @@ from typing import Any, ClassVar from ergon_core.api.evaluator import Rubric - from ergon_core.test_support.smoke_fixtures.criteria.minif2f_smoke import MiniF2FSmokeCriterion from ergon_core.test_support.smoke_fixtures.criteria.researchrubrics_smoke import ( ResearchRubricsSmokeCriterion, diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/swebench_smoke.py b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/swebench_smoke.py index 207d93fd..d2f41903 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/swebench_smoke.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/swebench_smoke.py @@ -18,9 +18,8 @@ from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution -from sqlmodel import col, desc, select - from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from sqlmodel import col, desc, select HEALTH_PY = """\ import sys diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py index 129a2a78..b113f2b5 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py @@ -33,11 +33,10 @@ from ergon_core.core.persistence.graph.status_conventions import COMPLETED from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution +from ergon_core.test_support.smoke_fixtures.smoke_base.constants import EXPECTED_SUBTASK_SLUGS from pydantic import BaseModel from sqlmodel import col, desc, select -from ergon_core.test_support.smoke_fixtures.smoke_base.constants import EXPECTED_SUBTASK_SLUGS - logger = logging.getLogger(__name__) @@ -155,7 +154,9 @@ async def _artifact_children( ).all(), ) nested_parent_ids = {node.parent_node_id for node in nested} - direct_artifact_children = [child for child in children if child.id not in nested_parent_ids] + direct_artifact_children = [ + child for child in children if child.id not in nested_parent_ids + ] return [*direct_artifact_children, *nested] async def _pull_probe_results( diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py index a09e9bf6..c419f8f9 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py @@ -8,7 +8,6 @@ from typing import ClassVar from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] - from ergon_core.api import WorkerContext from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug from ergon_core.core.runtime.services.task_management_dto import SubtaskSpec diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py index 1791c44e..3656d098 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py @@ -10,7 +10,6 @@ import json from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] - from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import ( RecursiveSmokeWorkerBase, diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py index 51e79ad2..debe8e82 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py @@ -17,7 +17,6 @@ import json from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] - from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import ( RecursiveSmokeWorkerBase, diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py index 9ccc38f4..90e3b9f4 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py @@ -6,7 +6,6 @@ ResearchRubricsSadPathSmokeWorker, ) - __all__ = [ "AlwaysFailSubworker", "ResearchRubricsFailingLeafWorker", diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py index e44c5c3d..ad877881 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py @@ -8,7 +8,6 @@ import json from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] - from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import ( RecursiveSmokeWorkerBase, diff --git a/ergon_core/migrations/versions/b5b36e45e5e6_add_containment_and_cancelled.py b/ergon_core/migrations/versions/b5b36e45e5e6_add_containment_and_cancelled.py index d1136ea8..a209382f 100644 --- a/ergon_core/migrations/versions/b5b36e45e5e6_add_containment_and_cancelled.py +++ b/ergon_core/migrations/versions/b5b36e45e5e6_add_containment_and_cancelled.py @@ -7,8 +7,8 @@ from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = "b5b36e45e5e6" diff --git a/ergon_core/migrations/versions/e96c85469899_rename_task_key_to_task_slug_and_.py b/ergon_core/migrations/versions/e96c85469899_rename_task_key_to_task_slug_and_.py index 74799f89..2ab6bbc1 100644 --- a/ergon_core/migrations/versions/e96c85469899_rename_task_key_to_task_slug_and_.py +++ b/ergon_core/migrations/versions/e96c85469899_rename_task_key_to_task_slug_and_.py @@ -9,7 +9,6 @@ from alembic import op - # revision identifiers, used by Alembic. revision: str = "e96c85469899" down_revision: Union[str, None] = "307fcca3a621" diff --git a/ergon_infra/ergon_infra/training/callback.py b/ergon_infra/ergon_infra/training/callback.py index f03eb523..28bc1cef 100644 --- a/ergon_infra/ergon_infra/training/callback.py +++ b/ergon_infra/ergon_infra/training/callback.py @@ -8,11 +8,10 @@ from typing import Callable from uuid import UUID -from sqlmodel import Session -from transformers import TrainerCallback, TrainerControl, TrainerState, TrainingArguments - from ergon_core.core.persistence.telemetry.models import TrainingMetric, TrainingSession from ergon_core.core.utils import utcnow +from sqlmodel import Session +from transformers import TrainerCallback, TrainerControl, TrainerState, TrainingArguments logger = logging.getLogger(__name__) diff --git a/scripts/check_suppression_budget.py b/scripts/check_suppression_budget.py index ba67312c..767b97ec 100644 --- a/scripts/check_suppression_budget.py +++ b/scripts/check_suppression_budget.py @@ -13,7 +13,6 @@ from pathlib import Path from typing import NamedTuple - DEFAULT_PATHS = ( "ergon_core", "ergon_builtins", diff --git a/scripts/train_trl_grpo.py b/scripts/train_trl_grpo.py index 6c0d6b4a..b4834586 100644 --- a/scripts/train_trl_grpo.py +++ b/scripts/train_trl_grpo.py @@ -26,7 +26,6 @@ ) from ergon_core.core.persistence.shared.db import ensure_db - from ergon_infra.training.config import training_config_from_args from ergon_infra.training.trl_runner import run_trl_training diff --git a/tests/e2e/_asserts.py b/tests/e2e/_asserts.py index a47ca20e..418c4755 100644 --- a/tests/e2e/_asserts.py +++ b/tests/e2e/_asserts.py @@ -25,8 +25,6 @@ from uuid import UUID import httpx -from sqlmodel import select - from ergon_core.core.api.schemas import RunTaskDto from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.graph.status_conventions import BLOCKED, COMPLETED, FAILED @@ -38,7 +36,6 @@ SandboxCommandWalEntry, SandboxEvent, ) - from ergon_core.test_support.smoke_fixtures.smoke_base.constants import EXPECTED_SUBTASK_SLUGS from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import ( @@ -46,6 +43,8 @@ RecursiveSmokeWorkerBase, ) from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase +from sqlmodel import select + from tests.e2e._read_contracts import require_run_snapshot TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"}) @@ -118,9 +117,7 @@ def _assert_run_resources(run_id: UUID) -> None: for resource in resources if resource.name.startswith("probe_") and resource.name.endswith(".json") ] - assert len(probes) == 10, ( - f"expected 10 probe_*.json (kind=report) resources, got {len(probes)}" - ) + assert len(probes) == 10, f"expected 10 probe_*.json (kind=report) resources, got {len(probes)}" worker_outputs = [resource for resource in resources if resource.name == "worker_output"] assert not worker_outputs, ( "worker final assistant messages must stay on executions, not resources" diff --git a/tests/e2e/test_minif2f_smoke.py b/tests/e2e/test_minif2f_smoke.py index 076d1ec5..90c3a488 100644 --- a/tests/e2e/test_minif2f_smoke.py +++ b/tests/e2e/test_minif2f_smoke.py @@ -26,8 +26,8 @@ _assert_sadpath_thread_messages, _assert_sandbox_command_wal, _assert_sandbox_lifecycle_events, - _assert_thread_messages_ordered, _assert_temporal_ordering, + _assert_thread_messages_ordered, wait_for_terminal_status, ) from tests.e2e._submit import submit_cohort diff --git a/tests/e2e/test_swebench_smoke.py b/tests/e2e/test_swebench_smoke.py index 367a2a90..4d3e2555 100644 --- a/tests/e2e/test_swebench_smoke.py +++ b/tests/e2e/test_swebench_smoke.py @@ -26,8 +26,8 @@ _assert_sandbox_command_wal, _assert_sandbox_lifecycle_events, _assert_swebench_artifacts, - _assert_thread_messages_ordered, _assert_temporal_ordering, + _assert_thread_messages_ordered, wait_for_terminal_status, ) from tests.e2e._submit import submit_cohort diff --git a/tests/integration/minif2f/test_sandbox_manager.py b/tests/integration/minif2f/test_sandbox_manager.py index 9818fc15..9df69456 100644 --- a/tests/integration/minif2f/test_sandbox_manager.py +++ b/tests/integration/minif2f/test_sandbox_manager.py @@ -8,12 +8,10 @@ from uuid import uuid4 import pytest - -from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager from ergon_builtins.benchmarks.minif2f.sandbox.utils import resolve_template +from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager from ergon_core.core.providers.sandbox.manager import BaseSandboxManager - # --------------------------------------------------------------------------- # Reset the singleton between tests — BaseSandboxManager stores _instance and # per-task dicts at class scope, so leaking state across tests is real. diff --git a/tests/integration/minif2f/test_verification_integration.py b/tests/integration/minif2f/test_verification_integration.py index 2438cb27..356ec8a0 100644 --- a/tests/integration/minif2f/test_verification_integration.py +++ b/tests/integration/minif2f/test_verification_integration.py @@ -15,12 +15,11 @@ from uuid import uuid4 import pytest - from ergon_builtins.benchmarks.minif2f.rules.proof_verification import ( ProofVerificationCriterion, ) -from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager from ergon_builtins.benchmarks.minif2f.sandbox.utils import REGISTRY_PATH +from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload diff --git a/tests/integration/propagation/_helpers.py b/tests/integration/propagation/_helpers.py index 9c6f50c5..cf4f4439 100644 --- a/tests/integration/propagation/_helpers.py +++ b/tests/integration/propagation/_helpers.py @@ -3,14 +3,13 @@ import time from uuid import UUID -from sqlmodel import Session, select - from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import RunStatus from ergon_core.core.persistence.telemetry.models import RunRecord +from sqlmodel import Session, select def poll_until(condition, *, timeout: float = 30, interval: float = 0.5) -> None: diff --git a/tests/integration/propagation/test_propagation_blocked.py b/tests/integration/propagation/test_propagation_blocked.py index 5c7f3240..e6de4cd0 100644 --- a/tests/integration/propagation/test_propagation_blocked.py +++ b/tests/integration/propagation/test_propagation_blocked.py @@ -1,8 +1,6 @@ """Tests for BLOCKED propagation semantics.""" import pytest -from sqlmodel import select - from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode from ergon_core.core.persistence.graph.status_conventions import BLOCKED, CANCELLED @@ -13,13 +11,14 @@ from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository from ergon_core.core.runtime.services.orchestration_dto import PropagateTaskCompletionCommand from ergon_core.core.runtime.services.task_propagation_service import TaskPropagationService +from sqlmodel import select from tests.integration.propagation._helpers import ( - assert_wal_has_status, assert_cross_cutting_invariants, + assert_wal_has_status, get_node_status, - make_experiment_definition, make_edge, + make_experiment_definition, make_node, make_run, seed_linear_chain, diff --git a/tests/integration/propagation/test_propagation_cancel.py b/tests/integration/propagation/test_propagation_cancel.py index bc9751a3..54319751 100644 --- a/tests/integration/propagation/test_propagation_cancel.py +++ b/tests/integration/propagation/test_propagation_cancel.py @@ -9,8 +9,6 @@ """ import pytest -from sqlmodel import select - from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode from ergon_core.core.persistence.graph.status_conventions import CANCELLED @@ -19,6 +17,7 @@ from ergon_core.core.persistence.telemetry.models import RunRecord from ergon_core.core.runtime.services.graph_dto import MutationMeta from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository +from sqlmodel import select from tests.integration.propagation._helpers import ( assert_cross_cutting_invariants, diff --git a/tests/integration/propagation/test_propagation_edge_cases.py b/tests/integration/propagation/test_propagation_edge_cases.py index 0f177198..bb72d1ed 100644 --- a/tests/integration/propagation/test_propagation_edge_cases.py +++ b/tests/integration/propagation/test_propagation_edge_cases.py @@ -5,8 +5,6 @@ """ import pytest -from sqlmodel import select - from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode from ergon_core.core.persistence.graph.status_conventions import BLOCKED, CANCELLED @@ -17,13 +15,14 @@ from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository from ergon_core.core.runtime.services.orchestration_dto import PropagateTaskCompletionCommand from ergon_core.core.runtime.services.task_propagation_service import TaskPropagationService +from sqlmodel import select from tests.integration.propagation._helpers import ( assert_cross_cutting_invariants, assert_wal_has_status, get_node_status, - make_experiment_definition, make_edge, + make_experiment_definition, make_node, make_run, ) diff --git a/tests/integration/propagation/test_propagation_happy.py b/tests/integration/propagation/test_propagation_happy.py index 573b7119..a17f2952 100644 --- a/tests/integration/propagation/test_propagation_happy.py +++ b/tests/integration/propagation/test_propagation_happy.py @@ -7,10 +7,6 @@ """ import pytest -from sqlalchemy import text -from sqlalchemy.exc import OperationalError -from sqlmodel import select - from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode from ergon_core.core.persistence.shared.db import get_engine, get_session @@ -20,6 +16,9 @@ from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository from ergon_core.core.runtime.services.orchestration_dto import PropagateTaskCompletionCommand from ergon_core.core.runtime.services.task_propagation_service import TaskPropagationService +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlmodel import select from tests.integration.propagation._helpers import ( assert_cross_cutting_invariants, diff --git a/tests/integration/propagation/test_propagation_restart.py b/tests/integration/propagation/test_propagation_restart.py index 1374c7df..8a3b006c 100644 --- a/tests/integration/propagation/test_propagation_restart.py +++ b/tests/integration/propagation/test_propagation_restart.py @@ -8,9 +8,9 @@ perspective on the same feature. """ -import pytest from unittest.mock import AsyncMock, patch +import pytest from ergon_core.core.persistence.graph.status_conventions import BLOCKED from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus @@ -20,8 +20,8 @@ from tests.integration.propagation._helpers import ( get_node_status, - make_experiment_definition, make_edge, + make_experiment_definition, make_node, make_run, ) diff --git a/tests/integration/restart/_helpers.py b/tests/integration/restart/_helpers.py index 0dfe59a2..6c8802ce 100644 --- a/tests/integration/restart/_helpers.py +++ b/tests/integration/restart/_helpers.py @@ -2,12 +2,11 @@ from uuid import UUID -from sqlmodel import select - from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunRecord +from sqlmodel import select def cleanup_run(run_id: UUID, defn_id: UUID) -> None: diff --git a/tests/integration/restart/test_downstream_invalidation.py b/tests/integration/restart/test_downstream_invalidation.py index e25bb560..fa90de81 100644 --- a/tests/integration/restart/test_downstream_invalidation.py +++ b/tests/integration/restart/test_downstream_invalidation.py @@ -7,11 +7,9 @@ - Deep cascade: A→B→C all COMPLETED — restart A cancels both B and C """ -import pytest from unittest.mock import AsyncMock, patch -from sqlmodel import select - +import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode from ergon_core.core.persistence.graph.status_conventions import ( @@ -24,6 +22,7 @@ from ergon_core.core.persistence.telemetry.models import RunRecord from ergon_core.core.runtime.services.task_management_dto import RestartTaskCommand from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from sqlmodel import select from tests.integration.propagation._helpers import ( get_node_status, diff --git a/tests/integration/restart/test_manager_dag_scenario.py b/tests/integration/restart/test_manager_dag_scenario.py index b834fc26..5d70dc54 100644 --- a/tests/integration/restart/test_manager_dag_scenario.py +++ b/tests/integration/restart/test_manager_dag_scenario.py @@ -18,9 +18,9 @@ across the full service stack. """ -import pytest from unittest.mock import AsyncMock, patch +import pytest from ergon_core.core.persistence.graph.status_conventions import CANCELLED, EDGE_PENDING from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus diff --git a/tests/integration/restart/test_reactivation.py b/tests/integration/restart/test_reactivation.py index 09c95303..6ddfddc6 100644 --- a/tests/integration/restart/test_reactivation.py +++ b/tests/integration/restart/test_reactivation.py @@ -12,9 +12,6 @@ """ import pytest - -from sqlmodel import select - from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode from ergon_core.core.persistence.graph.status_conventions import CANCELLED, EDGE_PENDING @@ -23,6 +20,7 @@ from ergon_core.core.persistence.telemetry.models import RunRecord from ergon_core.core.runtime.services.orchestration_dto import PropagateTaskCompletionCommand from ergon_core.core.runtime.services.task_propagation_service import TaskPropagationService +from sqlmodel import select from tests.integration.propagation._helpers import ( get_node_status, diff --git a/tests/integration/restart/test_restart_task.py b/tests/integration/restart/test_restart_task.py index 17ad670e..ab181d71 100644 --- a/tests/integration/restart/test_restart_task.py +++ b/tests/integration/restart/test_restart_task.py @@ -7,11 +7,9 @@ - refine_task: RUNNING raises TaskRunningError; non-running (COMPLETED) accepted """ -import pytest from unittest.mock import AsyncMock, patch -from sqlmodel import select - +import pytest from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode from ergon_core.core.persistence.graph.status_conventions import ( @@ -28,6 +26,7 @@ RestartTaskCommand, ) from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from sqlmodel import select from tests.integration.propagation._helpers import ( assert_wal_has_status, diff --git a/tests/integration/sandbox/test_required_env_keys.py b/tests/integration/sandbox/test_required_env_keys.py index fd25b1f5..616733d0 100644 --- a/tests/integration/sandbox/test_required_env_keys.py +++ b/tests/integration/sandbox/test_required_env_keys.py @@ -24,7 +24,6 @@ from uuid import uuid4 import pytest - from ergon_builtins.benchmarks.gdpeval.sandbox import GDPEvalSandboxManager from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager from ergon_builtins.benchmarks.swebench_verified.sandbox_manager import ( diff --git a/tests/integration/smokes/test_smoke_harness.py b/tests/integration/smokes/test_smoke_harness.py index 9b11d06b..6635167d 100644 --- a/tests/integration/smokes/test_smoke_harness.py +++ b/tests/integration/smokes/test_smoke_harness.py @@ -15,11 +15,10 @@ import httpx import pytest -from sqlalchemy import text -from sqlalchemy.exc import OperationalError - from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.shared.db import get_engine, get_session +from sqlalchemy import text +from sqlalchemy.exc import OperationalError pytestmark = pytest.mark.integration diff --git a/tests/integration/swebench_verified/conftest.py b/tests/integration/swebench_verified/conftest.py index 188effbc..a5e8297d 100644 --- a/tests/integration/swebench_verified/conftest.py +++ b/tests/integration/swebench_verified/conftest.py @@ -5,8 +5,6 @@ from uuid import UUID, uuid4 import pytest -from sqlmodel import select - from ergon_core.core.persistence.definitions.models import ( ExperimentDefinition, ExperimentDefinitionInstance, @@ -15,6 +13,7 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord, RunTaskExecution +from sqlmodel import select _MINIMAL_SWEBENCH_PAYLOAD: dict[str, object] = { "instance_id": "django__django-1", diff --git a/tests/integration/swebench_verified/test_benchmark.py b/tests/integration/swebench_verified/test_benchmark.py index 5b8e2e64..7037f9a3 100644 --- a/tests/integration/swebench_verified/test_benchmark.py +++ b/tests/integration/swebench_verified/test_benchmark.py @@ -5,8 +5,8 @@ from unittest.mock import patch from ergon_builtins.benchmarks.swebench_verified.benchmark import ( - _load_rows, SweBenchVerifiedBenchmark, + _load_rows, ) from ergon_builtins.benchmarks.swebench_verified.task_schemas import SWEBenchInstance diff --git a/tests/integration/swebench_verified/test_sandbox_manager.py b/tests/integration/swebench_verified/test_sandbox_manager.py index 593c9ad7..40d19255 100644 --- a/tests/integration/swebench_verified/test_sandbox_manager.py +++ b/tests/integration/swebench_verified/test_sandbox_manager.py @@ -7,14 +7,12 @@ from unittest.mock import AsyncMock, MagicMock import pytest - +from ergon_builtins.benchmarks.swebench_verified.sandbox.utils import resolve_template from ergon_builtins.benchmarks.swebench_verified.sandbox_manager import ( SWEBenchSandboxManager, ) -from ergon_builtins.benchmarks.swebench_verified.sandbox.utils import resolve_template from ergon_core.core.providers.sandbox.manager import BaseSandboxManager - # --------------------------------------------------------------------------- # Reset the singleton between tests — BaseSandboxManager stores _instance and # per-task dicts at class scope, so leaking state across tests is real. diff --git a/tests/integration/swebench_verified/test_task_schemas.py b/tests/integration/swebench_verified/test_task_schemas.py index 1eedf974..a4dcc371 100644 --- a/tests/integration/swebench_verified/test_task_schemas.py +++ b/tests/integration/swebench_verified/test_task_schemas.py @@ -1,14 +1,12 @@ """Tests for SWE-Bench task schemas.""" import pytest - from ergon_builtins.benchmarks.swebench_verified.task_schemas import ( SWEBenchInstance, SWEBenchTaskPayload, _parse_test_list, ) - RAW_ROW = { "instance_id": "django__django-11999", "repo": "django/django", diff --git a/tests/integration/swebench_verified/test_toolkit.py b/tests/integration/swebench_verified/test_toolkit.py index 5a58120c..67fe3e3c 100644 --- a/tests/integration/swebench_verified/test_toolkit.py +++ b/tests/integration/swebench_verified/test_toolkit.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock import pytest - from ergon_builtins.benchmarks.swebench_verified.toolkit import SWEBenchToolkit diff --git a/tests/real_llm/benchmarks/test_smoke_stub.py b/tests/real_llm/benchmarks/test_smoke_stub.py index 097fb557..0857ae38 100644 --- a/tests/real_llm/benchmarks/test_smoke_stub.py +++ b/tests/real_llm/benchmarks/test_smoke_stub.py @@ -14,10 +14,9 @@ from datetime import datetime, timezone import pytest -from sqlmodel import select - from ergon_core.core.persistence.shared.db import ensure_db, get_session from ergon_core.core.persistence.telemetry.models import RunRecord +from sqlmodel import select pytestmark = [pytest.mark.real_llm, pytest.mark.asyncio] diff --git a/tests/real_llm/fixtures/openrouter_budget.py b/tests/real_llm/fixtures/openrouter_budget.py index c3c78b51..f107a08a 100644 --- a/tests/real_llm/fixtures/openrouter_budget.py +++ b/tests/real_llm/fixtures/openrouter_budget.py @@ -4,7 +4,6 @@ from collections.abc import AsyncGenerator import pytest - from ergon_core.core.providers.generation.openrouter_budget import OpenRouterBudget from ergon_core.core.settings import settings diff --git a/tests/real_llm/rollout.py b/tests/real_llm/rollout.py index eb8005ad..f0fddb86 100644 --- a/tests/real_llm/rollout.py +++ b/tests/real_llm/rollout.py @@ -68,7 +68,9 @@ def _write_json_model(path: Path, row: Any) -> None: # slopcop: ignore[no-typin path.write_text(row.model_dump_json(indent=2)) -def _write_mapping_jsonl(path: Path, rows: list[dict[str, Any]]) -> int: # slopcop: ignore[no-typing-any] +def _write_mapping_jsonl( + path: Path, rows: list[dict[str, Any]] +) -> int: # slopcop: ignore[no-typing-any] """Write plain DB mapping rows as JSONL. Returns row count.""" with path.open("w") as f: for row in rows: @@ -92,9 +94,6 @@ def dump_rollout(run_id: UUID, out_dir: Path) -> dict[str, int]: # context event payload <-> worker API cycle when unit tests import the # pure report helpers from this module. The DB models are only needed for # live rollout dumping, so keep this import scoped to that operation. - from sqlalchemy import text - from sqlmodel import select - from ergon_core.core.persistence.graph.models import ( RunGraphEdge, RunGraphMutation, @@ -108,6 +107,8 @@ def dump_rollout(run_id: UUID, out_dir: Path) -> dict[str, int]: RunTaskExecution, SandboxEvent, ) + from sqlalchemy import text + from sqlmodel import select db_dir = out_dir / "db" counts: dict[str, int] = {} @@ -320,6 +321,10 @@ def write_report(out_dir: Path, manifest_path: Path) -> Path: f"- resources: {health.resource_count}", f"- graph nodes: {health.graph_node_count}", f"- criterion results: {health.criterion_count}", + f"- workflow tool calls: {health.workflow_tool_calls}", + f"- other tool calls: {health.other_tool_calls}", + f"- budget exhausted: {health.budget_exhausted}", + f"- missing final report: {health.missing_final_report}", ] ) if health.normalized_scores: diff --git a/tests/unit/architecture/test_no_test_logic_in_core.py b/tests/unit/architecture/test_no_test_logic_in_core.py index 1bde37d1..3d25feb7 100644 --- a/tests/unit/architecture/test_no_test_logic_in_core.py +++ b/tests/unit/architecture/test_no_test_logic_in_core.py @@ -1,6 +1,5 @@ from pathlib import Path - ROOT = Path(__file__).resolve().parents[3] CORE = ROOT / "ergon_core" / "ergon_core" / "core" diff --git a/tests/unit/architecture/test_persistence_boundaries.py b/tests/unit/architecture/test_persistence_boundaries.py index dd1db29e..ef3f7e4f 100644 --- a/tests/unit/architecture/test_persistence_boundaries.py +++ b/tests/unit/architecture/test_persistence_boundaries.py @@ -2,7 +2,6 @@ from pathlib import Path - FORBIDDEN_PATTERNS = ( "get_session(", "session.exec(", diff --git a/tests/unit/benchmarks/test_swebench_sandbox_manager.py b/tests/unit/benchmarks/test_swebench_sandbox_manager.py index 6898f3c4..2462cd35 100644 --- a/tests/unit/benchmarks/test_swebench_sandbox_manager.py +++ b/tests/unit/benchmarks/test_swebench_sandbox_manager.py @@ -4,7 +4,6 @@ from uuid import uuid4 import pytest - from ergon_builtins.benchmarks.swebench_verified.sandbox_manager import ( SWEBenchSandboxManager, ) diff --git a/tests/unit/builtins/common/test_capture_settings.py b/tests/unit/builtins/common/test_capture_settings.py index ed319bfd..26ef327a 100644 --- a/tests/unit/builtins/common/test_capture_settings.py +++ b/tests/unit/builtins/common/test_capture_settings.py @@ -25,9 +25,35 @@ def test_anthropic_enables_thinking() -> None: } +def test_anthropic_opus_47_uses_adaptive_summarized_thinking() -> None: + assert capture_model_settings_for("anthropic:claude-opus-4.7") == { + "anthropic_thinking": {"type": "adaptive", "display": "summarized"}, + "anthropic_effort": "medium", + } + + def test_openrouter_includes_reasoning() -> None: assert capture_model_settings_for("openrouter:anthropic/claude-sonnet-4.6") == { - "openrouter_reasoning": {"enabled": True, "exclude": False}, + "openrouter_reasoning": {"max_tokens": 4096, "exclude": False}, + } + + +def test_openrouter_opus_uses_larger_reasoning_budget() -> None: + assert capture_model_settings_for("openrouter:anthropic/claude-opus-4.7") == { + "openrouter_reasoning": {"max_tokens": 8192, "exclude": False}, + } + + +def test_openrouter_openai_uses_reasoning_effort() -> None: + assert capture_model_settings_for("openrouter:openai/gpt-5.1") == { + "openrouter_reasoning": {"effort": "medium", "exclude": False}, + } + + +def test_openai_responses_uses_detailed_reasoning_summary() -> None: + assert capture_model_settings_for("openai-responses:gpt-5.5-pro") == { + "openai_reasoning_effort": "medium", + "openai_reasoning_summary": "detailed", } diff --git a/tests/unit/cli/test_benchmark_setup.py b/tests/unit/cli/test_benchmark_setup.py index 4a32a0bd..f871c0f7 100644 --- a/tests/unit/cli/test_benchmark_setup.py +++ b/tests/unit/cli/test_benchmark_setup.py @@ -5,9 +5,8 @@ from unittest.mock import MagicMock import e2b -import pytest - import ergon_cli.commands.benchmark as _bench_mod +import pytest from ergon_cli.commands.benchmark import setup_benchmark from ergon_core.core.settings import settings diff --git a/tests/unit/cli/test_eval_cli_required_fields.py b/tests/unit/cli/test_eval_cli_required_fields.py index 86098fa7..bd0b3333 100644 --- a/tests/unit/cli/test_eval_cli_required_fields.py +++ b/tests/unit/cli/test_eval_cli_required_fields.py @@ -1,5 +1,4 @@ import pytest - from ergon_cli.main import build_parser diff --git a/tests/unit/cli/test_workflow_cli.py b/tests/unit/cli/test_workflow_cli.py index 4737e8a7..c1efda82 100644 --- a/tests/unit/cli/test_workflow_cli.py +++ b/tests/unit/cli/test_workflow_cli.py @@ -3,7 +3,6 @@ from datetime import UTC, datetime from uuid import uuid4 -import pytest from ergon_cli.commands.workflow import WorkflowCommandContext, execute_workflow_command from ergon_core.core.runtime.services.task_management_dto import AddSubtaskResult from ergon_core.core.runtime.services.workflow_dto import WorkflowResourceRef @@ -61,6 +60,21 @@ async def add_task( ) +class _FailingService: + def list_resources(self, *args, **kwargs): + raise ValueError("unsupported resource scope: all") + + +def _context() -> WorkflowCommandContext: + return WorkflowCommandContext( + run_id=uuid4(), + node_id=uuid4(), + execution_id=uuid4(), + sandbox_task_key=uuid4(), + benchmark_type="researchrubrics", + ) + + def test_resource_list_json_uses_injected_context() -> None: run_id = uuid4() node_id = uuid4() @@ -100,31 +114,22 @@ def test_resource_list_json_uses_injected_context() -> None: def test_agent_command_rejects_user_supplied_context_flags() -> None: - with pytest.raises(ValueError, match="scope/context flags are injected"): - execute_workflow_command( - f"inspect resource-list --scope visible --run-id {uuid4()}", - context=WorkflowCommandContext( - run_id=uuid4(), - node_id=uuid4(), - execution_id=uuid4(), - sandbox_task_key=uuid4(), - benchmark_type="researchrubrics", - ), - session_factory=_Session, - service=_Service(resource=None), # type: ignore[arg-type] - ) + output = execute_workflow_command( + f"inspect resource-list --scope visible --run-id {uuid4()}", + context=_context(), + session_factory=_Session, + service=_Service(resource=None), # type: ignore[arg-type] + ) + + assert output.exit_code == 2 + assert output.stderr is not None + assert "scope/context flags are injected" in output.stderr def test_parse_error_returns_nonzero_output_instead_of_system_exit() -> None: output = execute_workflow_command( "manage materialize-resource", - context=WorkflowCommandContext( - run_id=uuid4(), - node_id=uuid4(), - execution_id=uuid4(), - sandbox_task_key=uuid4(), - benchmark_type="researchrubrics", - ), + context=_context(), session_factory=_Session, service=_Service(resource=None), # type: ignore[arg-type] ) @@ -134,6 +139,63 @@ def test_parse_error_returns_nonzero_output_instead_of_system_exit() -> None: assert "--resource-id" in output.stderr +def test_invalid_resource_scope_returns_choices_without_service_call() -> None: + output = execute_workflow_command( + "inspect resource-list --scope all", + context=_context(), + session_factory=_Session, + service=_Service(resource=None), # type: ignore[arg-type] + ) + + assert output.exit_code == 2 + assert output.stderr is not None + assert "invalid choice: 'all'" in output.stderr + assert "visible" in output.stderr + assert "descendants" in output.stderr + assert "workflow inspect resource-list --help" in output.stderr + + +def test_invalid_resource_kind_returns_choices_without_service_call() -> None: + output = execute_workflow_command( + "inspect resource-list --scope visible --kind everything", + context=_context(), + session_factory=_Session, + service=_Service(resource=None), # type: ignore[arg-type] + ) + + assert output.exit_code == 2 + assert output.stderr is not None + assert "invalid choice: 'everything'" in output.stderr + assert "report" in output.stderr + assert "search_cache" in output.stderr + assert "workflow inspect resource-list --help" in output.stderr + + +def test_malformed_resource_uuid_returns_nonzero_output() -> None: + output = execute_workflow_command( + "inspect resource-content --resource-id not-a-uuid", + context=_context(), + session_factory=_Session, + service=_Service(resource=None), # type: ignore[arg-type] + ) + + assert output.exit_code == 2 + assert output.stderr is not None + assert "badly formed hexadecimal UUID string" in output.stderr + + +def test_service_validation_error_returns_nonzero_output() -> None: + output = execute_workflow_command( + "inspect resource-list --scope visible", + context=_context(), + session_factory=_Session, + service=_FailingService(), # type: ignore[arg-type] + ) + + assert output.exit_code == 2 + assert output.stderr == "unsupported resource scope: all" + + def test_manage_add_task_creates_subtask_with_injected_parent_context() -> None: run_id = uuid4() node_id = uuid4() diff --git a/tests/unit/persistence/test_context_event_repository.py b/tests/unit/persistence/test_context_event_repository.py index 9dc22b13..d4aadeaf 100644 --- a/tests/unit/persistence/test_context_event_repository.py +++ b/tests/unit/persistence/test_context_event_repository.py @@ -1,7 +1,7 @@ from uuid import uuid4 import pytest -from ergon_core.api.generation import ( +from ergon_core.core.generation import ( GenerationTurn, TextPart, ThinkingPart, @@ -9,8 +9,8 @@ ToolReturnPart, UserPromptPart, ) -from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.context.repository import ContextEventRepository +from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord, RunTaskExecution diff --git a/tests/unit/registry/test_react_factories.py b/tests/unit/registry/test_react_factories.py index 941227a7..6aa37a70 100644 --- a/tests/unit/registry/test_react_factories.py +++ b/tests/unit/registry/test_react_factories.py @@ -4,7 +4,6 @@ from uuid import uuid4 import pytest - from ergon_builtins.registry_core import WORKERS from ergon_core.api import Worker @@ -34,12 +33,12 @@ def test_minif2f_factory_builds_toolkit(monkeypatch: pytest.MonkeyPatch) -> None """The minif2f factory must construct a live toolkit bound to the sandbox.""" # reason: imports deferred to avoid pulling registry_core + sandbox_manager # eagerly into test collection. Every test pulls its own patch target. - from ergon_builtins import registry_core - # reason: only needed for MagicMock spec= below; eager import would pull # the benchmark sandbox module into all registry tests. from ergon_builtins.benchmarks.minif2f import sandbox_manager as sm_mod + from ergon_builtins import registry_core + fake_sandbox = MagicMock(name="fake-sandbox") fake_manager = MagicMock(spec=sm_mod.MiniF2FSandboxManager) fake_manager.get_sandbox.return_value = fake_sandbox diff --git a/tests/unit/runtime/test_communication_service.py b/tests/unit/runtime/test_communication_service.py index f64c8115..79bb43b3 100644 --- a/tests/unit/runtime/test_communication_service.py +++ b/tests/unit/runtime/test_communication_service.py @@ -2,11 +2,10 @@ from uuid import uuid4 import pytest -from sqlalchemy.pool import StaticPool -from sqlmodel import Session, SQLModel, create_engine, select - from ergon_core.core.runtime.services import communication_service as module from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select Thread = module.Thread diff --git a/tests/unit/runtime/test_criterion_runtime_get_all_files.py b/tests/unit/runtime/test_criterion_runtime_get_all_files.py index d8dffe7c..f77c9923 100644 --- a/tests/unit/runtime/test_criterion_runtime_get_all_files.py +++ b/tests/unit/runtime/test_criterion_runtime_get_all_files.py @@ -15,7 +15,6 @@ from uuid import uuid4 import pytest - from ergon_core.core.runtime.evaluation.criterion_runtime import ( CriterionRuntimeOptions, DefaultCriterionRuntime, diff --git a/tests/unit/runtime/test_criterion_runtime_reconnect.py b/tests/unit/runtime/test_criterion_runtime_reconnect.py index 8c4dd34b..50ef8bab 100644 --- a/tests/unit/runtime/test_criterion_runtime_reconnect.py +++ b/tests/unit/runtime/test_criterion_runtime_reconnect.py @@ -12,7 +12,6 @@ from uuid import uuid4 import pytest - from ergon_core.core.providers.sandbox.errors import SandboxExpiredError from ergon_core.core.runtime.evaluation.criterion_runtime import ( CriterionRuntimeOptions, diff --git a/tests/unit/runtime/test_definition_task_payload_typing.py b/tests/unit/runtime/test_definition_task_payload_typing.py index a36511b7..5958eff0 100644 --- a/tests/unit/runtime/test_definition_task_payload_typing.py +++ b/tests/unit/runtime/test_definition_task_payload_typing.py @@ -1,8 +1,7 @@ from uuid import uuid4 -from pydantic import BaseModel - from ergon_core.core.persistence.definitions.models import ExperimentDefinitionTask +from pydantic import BaseModel class ExampleTaskPayload(BaseModel): diff --git a/tests/unit/runtime/test_failed_task_sandbox_cleanup.py b/tests/unit/runtime/test_failed_task_sandbox_cleanup.py index 1fb202c9..e8e52b04 100644 --- a/tests/unit/runtime/test_failed_task_sandbox_cleanup.py +++ b/tests/unit/runtime/test_failed_task_sandbox_cleanup.py @@ -1,7 +1,6 @@ from unittest.mock import AsyncMock, patch import pytest - from ergon_core.core.providers.sandbox.lifecycle import ( SandboxTerminationReason, SandboxTerminationResult, diff --git a/tests/unit/runtime/test_failure_error_json.py b/tests/unit/runtime/test_failure_error_json.py index 4409eb44..c4807411 100644 --- a/tests/unit/runtime/test_failure_error_json.py +++ b/tests/unit/runtime/test_failure_error_json.py @@ -3,7 +3,6 @@ from uuid import uuid4 import pytest - from ergon_core.core.runtime.services.orchestration_dto import FailTaskExecutionCommand diff --git a/tests/unit/runtime/test_run_record_missing_error.py b/tests/unit/runtime/test_run_record_missing_error.py index 6f412974..fe2e5516 100644 --- a/tests/unit/runtime/test_run_record_missing_error.py +++ b/tests/unit/runtime/test_run_record_missing_error.py @@ -4,7 +4,6 @@ from uuid import uuid4 import pytest - from ergon_core.core.runtime.errors.delegation_errors import RunRecordMissingError from ergon_core.core.runtime.services.task_management_service import TaskManagementService diff --git a/tests/unit/runtime/test_smoke_topology_drift.py b/tests/unit/runtime/test_smoke_topology_drift.py index 6a1eaa6e..727859af 100644 --- a/tests/unit/runtime/test_smoke_topology_drift.py +++ b/tests/unit/runtime/test_smoke_topology_drift.py @@ -3,8 +3,8 @@ from __future__ import annotations import ast -from pathlib import Path import re +from pathlib import Path from ergon_core.test_support.smoke_fixtures.smoke_base.constants import ( EXPECTED_SUBTASK_SLUGS, diff --git a/tests/unit/sandbox/test_ensure_sandbox_idempotence.py b/tests/unit/sandbox/test_ensure_sandbox_idempotence.py index 83e71520..0ff301d2 100644 --- a/tests/unit/sandbox/test_ensure_sandbox_idempotence.py +++ b/tests/unit/sandbox/test_ensure_sandbox_idempotence.py @@ -12,7 +12,6 @@ from uuid import UUID, uuid4 import pytest - from ergon_core.core.providers.sandbox.manager import BaseSandboxManager diff --git a/tests/unit/sandbox/test_sandbox_lifecycle_service.py b/tests/unit/sandbox/test_sandbox_lifecycle_service.py index 2753ab48..c1403a34 100644 --- a/tests/unit/sandbox/test_sandbox_lifecycle_service.py +++ b/tests/unit/sandbox/test_sandbox_lifecycle_service.py @@ -1,7 +1,6 @@ from unittest.mock import AsyncMock, patch import pytest - from ergon_core.core.providers.sandbox.lifecycle import ( SandboxTerminationReason, terminate_sandbox_by_id, diff --git a/tests/unit/sandbox/test_sandbox_reconnect.py b/tests/unit/sandbox/test_sandbox_reconnect.py index f9c84118..bde1e87c 100644 --- a/tests/unit/sandbox/test_sandbox_reconnect.py +++ b/tests/unit/sandbox/test_sandbox_reconnect.py @@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock import pytest - from ergon_core.core.providers.sandbox.errors import SandboxExpiredError from ergon_core.core.providers.sandbox.manager import BaseSandboxManager diff --git a/tests/unit/smoke_base/test_always_fail_subworker.py b/tests/unit/smoke_base/test_always_fail_subworker.py index 6cacf3ff..bdd02bb8 100644 --- a/tests/unit/smoke_base/test_always_fail_subworker.py +++ b/tests/unit/smoke_base/test_always_fail_subworker.py @@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, MagicMock import pytest - from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke_sadpath import ( AlwaysFailSubworker, ) diff --git a/tests/unit/smoke_base/test_leaf_sends_completion_message.py b/tests/unit/smoke_base/test_leaf_sends_completion_message.py index 22865dac..3ba972ab 100644 --- a/tests/unit/smoke_base/test_leaf_sends_completion_message.py +++ b/tests/unit/smoke_base/test_leaf_sends_completion_message.py @@ -9,7 +9,6 @@ from uuid import uuid4 import pytest - from ergon_core.api import BenchmarkTask from ergon_core.core.persistence.shared.types import AssignedWorkerSlug from ergon_core.core.providers.sandbox.manager import AsyncSandbox diff --git a/tests/unit/smoke_base/test_minif2f_criterion.py b/tests/unit/smoke_base/test_minif2f_criterion.py index bd8e30aa..6ae2e2d9 100644 --- a/tests/unit/smoke_base/test_minif2f_criterion.py +++ b/tests/unit/smoke_base/test_minif2f_criterion.py @@ -3,7 +3,6 @@ from unittest.mock import AsyncMock, MagicMock import pytest - from ergon_core.api.errors import CriteriaCheckError from ergon_core.test_support.smoke_fixtures.criteria.minif2f_smoke import MiniF2FSmokeCriterion diff --git a/tests/unit/smoke_base/test_recursive_smoke_worker_routing.py b/tests/unit/smoke_base/test_recursive_smoke_worker_routing.py index 941cd90f..1730f700 100644 --- a/tests/unit/smoke_base/test_recursive_smoke_worker_routing.py +++ b/tests/unit/smoke_base/test_recursive_smoke_worker_routing.py @@ -1,7 +1,6 @@ from uuid import uuid4 import pytest - from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug from ergon_core.test_support.smoke_fixtures.workers.minif2f_smoke import MiniF2FSmokeWorker from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke import ( diff --git a/tests/unit/smoke_base/test_registry_smoke_entries.py b/tests/unit/smoke_base/test_registry_smoke_entries.py index 6702ce3e..523777cc 100644 --- a/tests/unit/smoke_base/test_registry_smoke_entries.py +++ b/tests/unit/smoke_base/test_registry_smoke_entries.py @@ -10,8 +10,8 @@ def test_researchrubrics_slugs_registered() -> None: - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures from ergon_builtins.registry import EVALUATORS, WORKERS + from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures register_smoke_fixtures() @@ -30,8 +30,8 @@ def test_researchrubrics_slugs_registered() -> None: def test_no_retired_slugs_present() -> None: - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures from ergon_builtins.registry import WORKERS + from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures register_smoke_fixtures() @@ -49,8 +49,8 @@ def test_register_is_idempotent() -> None: def test_minif2f_slugs_registered() -> None: - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures from ergon_builtins.registry import EVALUATORS, WORKERS + from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures register_smoke_fixtures() @@ -63,8 +63,8 @@ def test_minif2f_slugs_registered() -> None: def test_swebench_slugs_registered() -> None: - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures from ergon_builtins.registry import EVALUATORS, WORKERS + from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures register_smoke_fixtures() @@ -79,8 +79,8 @@ def test_swebench_slugs_registered() -> None: def test_smoke_benchmarks_are_test_owned_when_harness_enabled( monkeypatch: pytest.MonkeyPatch, ) -> None: - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures from ergon_builtins.registry import BENCHMARKS, SANDBOX_MANAGERS + from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager slugs = ("researchrubrics", "minif2f", "swebench-verified") diff --git a/tests/unit/smoke_base/test_researchrubrics_criterion.py b/tests/unit/smoke_base/test_researchrubrics_criterion.py index e1940fe1..9023d2de 100644 --- a/tests/unit/smoke_base/test_researchrubrics_criterion.py +++ b/tests/unit/smoke_base/test_researchrubrics_criterion.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock import pytest - from ergon_core.api.errors import CriteriaCheckError from ergon_core.test_support.smoke_fixtures.criteria.researchrubrics_smoke import ( ResearchRubricsSmokeCriterion, diff --git a/tests/unit/smoke_base/test_sadpath_worker_routing.py b/tests/unit/smoke_base/test_sadpath_worker_routing.py index 9cad3fe5..15d6cae1 100644 --- a/tests/unit/smoke_base/test_sadpath_worker_routing.py +++ b/tests/unit/smoke_base/test_sadpath_worker_routing.py @@ -6,9 +6,8 @@ from uuid import uuid4 -from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug import pytest - +from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug from ergon_core.test_support.smoke_fixtures.workers.minif2f_smoke import ( MiniF2FSadPathSmokeWorker, ) diff --git a/tests/unit/smoke_base/test_smoke_criterion_completed.py b/tests/unit/smoke_base/test_smoke_criterion_completed.py index 91a9b787..5f7044ff 100644 --- a/tests/unit/smoke_base/test_smoke_criterion_completed.py +++ b/tests/unit/smoke_base/test_smoke_criterion_completed.py @@ -1,11 +1,10 @@ """``SmokeCriterionBase._check_children_completed`` rejects non-terminal children.""" import pytest -from pydantic import BaseModel - from ergon_core.api.errors import CriteriaCheckError from ergon_core.core.persistence.graph.status_conventions import COMPLETED from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from pydantic import BaseModel class _FakeNode(BaseModel): diff --git a/tests/unit/smoke_base/test_smoke_criterion_probe.py b/tests/unit/smoke_base/test_smoke_criterion_probe.py index c3d9f5dc..b7fc5503 100644 --- a/tests/unit/smoke_base/test_smoke_criterion_probe.py +++ b/tests/unit/smoke_base/test_smoke_criterion_probe.py @@ -3,13 +3,12 @@ from uuid import UUID, uuid4 import pytest -from pydantic import BaseModel - from ergon_core.api.errors import CriteriaCheckError from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import ( ProbeResult, SmokeCriterionBase, ) +from pydantic import BaseModel class _FakeNode(BaseModel): diff --git a/tests/unit/smoke_base/test_smoke_criterion_shape.py b/tests/unit/smoke_base/test_smoke_criterion_shape.py index eec280e4..b3fee661 100644 --- a/tests/unit/smoke_base/test_smoke_criterion_shape.py +++ b/tests/unit/smoke_base/test_smoke_criterion_shape.py @@ -5,11 +5,10 @@ """ import pytest -from pydantic import BaseModel - from ergon_core.api.errors import CriteriaCheckError from ergon_core.test_support.smoke_fixtures.smoke_base.constants import EXPECTED_SUBTASK_SLUGS from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from pydantic import BaseModel class _FakeNode(BaseModel): diff --git a/tests/unit/smoke_base/test_smoke_sandbox_manager.py b/tests/unit/smoke_base/test_smoke_sandbox_manager.py index 661cceb7..35d22663 100644 --- a/tests/unit/smoke_base/test_smoke_sandbox_manager.py +++ b/tests/unit/smoke_base/test_smoke_sandbox_manager.py @@ -2,7 +2,6 @@ from uuid import UUID, uuid4 import pytest - from ergon_core.core.providers.sandbox.event_sink import SandboxEventSink @@ -121,6 +120,7 @@ async def test_static_teardown_closes_registered_smoke_sandbox() -> None: def test_smoke_benchmarks_use_smoke_sandbox_manager( monkeypatch: pytest.MonkeyPatch, ) -> None: + from ergon_builtins.registry import BENCHMARKS, SANDBOX_MANAGERS from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures from ergon_core.test_support.smoke_fixtures.benchmarks import ( MiniF2FSmokeBenchmark, @@ -128,7 +128,6 @@ def test_smoke_benchmarks_use_smoke_sandbox_manager( SweBenchSmokeBenchmark, ) from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager - from ergon_builtins.registry import BENCHMARKS, SANDBOX_MANAGERS slugs = ( ResearchRubricsSmokeBenchmark.type_slug, diff --git a/tests/unit/smoke_base/test_smoke_worker_base_final.py b/tests/unit/smoke_base/test_smoke_worker_base_final.py index 1339f770..14ad2369 100644 --- a/tests/unit/smoke_base/test_smoke_worker_base_final.py +++ b/tests/unit/smoke_base/test_smoke_worker_base_final.py @@ -11,7 +11,6 @@ """ import pytest - from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase diff --git a/tests/unit/smoke_base/test_swebench_criterion.py b/tests/unit/smoke_base/test_swebench_criterion.py index 3f9b0bec..c0079264 100644 --- a/tests/unit/smoke_base/test_swebench_criterion.py +++ b/tests/unit/smoke_base/test_swebench_criterion.py @@ -3,7 +3,6 @@ from unittest.mock import AsyncMock, MagicMock import pytest - from ergon_core.api.errors import CriteriaCheckError from ergon_core.test_support.smoke_fixtures.criteria.swebench_smoke import SweBenchSmokeCriterion diff --git a/tests/unit/state/test_benchmark_contract.py b/tests/unit/state/test_benchmark_contract.py index 862b0bd8..9f96f808 100644 --- a/tests/unit/state/test_benchmark_contract.py +++ b/tests/unit/state/test_benchmark_contract.py @@ -1,13 +1,11 @@ """Contract: every registered benchmark declares onboarding_deps.""" import pytest -from pydantic import BaseModel -from pydantic import ValidationError - from ergon_builtins.registry_core import BENCHMARKS as CORE_BENCHMARKS from ergon_core.api.benchmark import Benchmark from ergon_core.api.benchmark_deps import BenchmarkDeps from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload +from pydantic import BaseModel, ValidationError def _require_onboarding_deps(slug: str, cls: type[Benchmark]) -> BenchmarkDeps: diff --git a/tests/unit/state/test_openrouter_model_resolution.py b/tests/unit/state/test_openrouter_model_resolution.py index 80525f84..ce5d09d3 100644 --- a/tests/unit/state/test_openrouter_model_resolution.py +++ b/tests/unit/state/test_openrouter_model_resolution.py @@ -1,7 +1,6 @@ -from ergon_builtins.models.resolution import resolve_model_target - # Importing the builtins registry registers production model backends. import ergon_builtins.registry # noqa: F401 +from ergon_builtins.models.resolution import resolve_model_target def test_openrouter_target_resolves_to_openrouter_provider_model() -> None: @@ -12,5 +11,17 @@ def test_openrouter_target_resolves_to_openrouter_provider_model() -> None: assert resolved.model.system == "openrouter" assert resolved.supports_logprobs is False assert resolved.capture_model_settings == { - "openrouter_reasoning": {"enabled": True, "exclude": False}, + "openrouter_reasoning": {"max_tokens": 4096, "exclude": False}, + } + + +def test_openai_responses_target_routes_through_openrouter_responses() -> None: + resolved = resolve_model_target("openai-responses:gpt-5.5-pro") + + assert type(resolved.model).__name__ == "OpenAIResponsesModel" + assert resolved.model.model_name == "openai/gpt-5.5-pro" + assert resolved.supports_logprobs is False + assert resolved.capture_model_settings == { + "openai_reasoning_effort": "medium", + "openai_reasoning_summary": "detailed", } diff --git a/tests/unit/state/test_subtask_lifecycle_toolkit.py b/tests/unit/state/test_subtask_lifecycle_toolkit.py index 7d836907..04836047 100644 --- a/tests/unit/state/test_subtask_lifecycle_toolkit.py +++ b/tests/unit/state/test_subtask_lifecycle_toolkit.py @@ -3,7 +3,6 @@ from uuid import uuid4 import pytest - from ergon_builtins.tools.subtask_lifecycle_toolkit import ( SubtaskLifecycleToolkit, ToolFailure, diff --git a/tests/unit/state/test_type_invariants.py b/tests/unit/state/test_type_invariants.py index e0061dab..819b3c97 100644 --- a/tests/unit/state/test_type_invariants.py +++ b/tests/unit/state/test_type_invariants.py @@ -7,10 +7,9 @@ is empty as a result; see the inline comment for details. """ -import pytest -from pydantic import ValidationError from uuid import uuid4 +import pytest from ergon_core.core.persistence.graph.models import ( RunGraphAnnotation, RunGraphMutation, @@ -29,7 +28,7 @@ RunTaskExecution, TrainingSession, ) - +from pydantic import ValidationError # --------------------------------------------------------------------------- # Happy path — field accepts valid value and stores it diff --git a/tests/unit/state/test_workflow_cli_tool.py b/tests/unit/state/test_workflow_cli_tool.py index 9805e514..63f48148 100644 --- a/tests/unit/state/test_workflow_cli_tool.py +++ b/tests/unit/state/test_workflow_cli_tool.py @@ -2,6 +2,10 @@ import pytest from ergon_builtins.tools.workflow_cli_tool import make_workflow_cli_tool +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetState, +) from ergon_cli.commands.workflow import WorkflowCommandOutput, execute_workflow_command from ergon_core.api.worker_context import WorkerContext @@ -120,6 +124,48 @@ def close(self): session_factory=Session, ) - result = await workflow("manage add-task --task-slug source --worker worker --description x --dry-run") + result = await workflow( + "manage add-task --task-slug source --worker worker --description x --dry-run" + ) assert "Graph lifecycle command validated" in result + + +@pytest.mark.asyncio +async def test_budgeted_workflow_tool_returns_structured_exhaustion() -> None: + context = WorkerContext( + run_id=uuid4(), + task_id=uuid4(), + execution_id=uuid4(), + sandbox_id="sandbox", + node_id=uuid4(), + ) + calls = 0 + + def execute(command, *, context, session_factory, service): + nonlocal calls + calls += 1 + return WorkflowCommandOutput(stdout="ok") + + workflow = make_workflow_cli_tool( + worker_context=context, + sandbox_task_key=context.task_id, + benchmark_type="researchrubrics", + execute_command=execute, + budgeted=True, + ) + deps = AgentToolBudgetDeps( + tool_budget=AgentToolBudgetState( + max_workflow_tool_calls=1, + max_other_tool_calls=1, + ), + ) + ctx = type("Ctx", (), {"deps": deps})() + + first = await workflow(ctx, "inspect task-tree") + exhausted = await workflow(ctx, "inspect task-tree") + + assert first == "ok" + assert exhausted.status == "TOOL_BUDGET_EXHAUSTED" + assert exhausted.reason == "workflow tool budget reached" + assert calls == 1 diff --git a/tests/unit/test_dashboard_emitter_wiring.py b/tests/unit/test_dashboard_emitter_wiring.py index e5d9267f..fc8e1db7 100644 --- a/tests/unit/test_dashboard_emitter_wiring.py +++ b/tests/unit/test_dashboard_emitter_wiring.py @@ -13,7 +13,6 @@ from typing import Final import pytest - from ergon_core.core.dashboard.emitter import DashboardEmitter # --------------------------------------------------------------------------- diff --git a/tests/unit/test_openrouter_budget.py b/tests/unit/test_openrouter_budget.py index 18479e64..3ce99109 100644 --- a/tests/unit/test_openrouter_budget.py +++ b/tests/unit/test_openrouter_budget.py @@ -3,8 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest - -from ergon_core.core.providers.generation.openrouter_budget import OpenRouterBudget +from tests.real_llm.openrouter_budget import OpenRouterBudget def _make_mock_response( diff --git a/tests/unit/test_test_harness.py b/tests/unit/test_test_harness.py index 3f34038d..ae293418 100644 --- a/tests/unit/test_test_harness.py +++ b/tests/unit/test_test_harness.py @@ -4,12 +4,11 @@ from uuid import uuid4 import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - from ergon_core.core.api import test_harness from ergon_core.core.api.startup_plugins import run_startup_plugins from ergon_core.core.api.test_harness import get_session_dep, router +from fastapi import FastAPI +from fastapi.testclient import TestClient class _NullSession: diff --git a/tests/unit/workers/test_react_worker_contract.py b/tests/unit/workers/test_react_worker_contract.py index 4a34ecbc..064df348 100644 --- a/tests/unit/workers/test_react_worker_contract.py +++ b/tests/unit/workers/test_react_worker_contract.py @@ -3,9 +3,12 @@ import inspect from uuid import UUID +import ergon_builtins.workers.baselines.react_worker as react_worker_module import pytest - from ergon_builtins.workers.baselines.react_worker import ReActWorker +from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload +from ergon_core.api.worker_context import WorkerContext +from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart def test_no_adapter_kwarg() -> None: @@ -54,9 +57,177 @@ def test_construct_with_minimal_explicit_kwargs() -> None: def test_pydantic_ai_transcript_adapter_lives_outside_worker() -> None: - import ergon_builtins.workers.baselines.react_worker as react_worker + module_symbols = vars(react_worker_module) + assert "_build_turns" not in module_symbols + assert "_extract_request_parts" not in module_symbols + assert "_extract_response_parts" not in module_symbols + assert "_extract_tool_results" not in module_symbols + + +class _FakeRunState: + def __init__(self) -> None: + self.message_history = [ + ModelRequest(parts=[UserPromptPart(content="question")]), + ModelResponse(parts=[TextPart(content="partial answer")]), + ] + + +class _FakeRunContext: + def __init__(self) -> None: + self.state = _FakeRunState() + + +class _FailingAgentRun: + def __init__(self) -> None: + self.ctx = _FakeRunContext() + + def __aiter__(self): + return self + + async def __anext__(self): + raise RuntimeError("tool validation failed") + + +class _FailingAgentIter: + async def __aenter__(self): + return _FailingAgentRun() + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FailingAgent: + def __init__(self, **kwargs) -> None: + pass + + def iter(self, *args, **kwargs): + return _FailingAgentIter() + + +class _DepsAgentRun: + def __init__(self) -> None: + self.ctx = _FakeRunContext() + self._yielded = False + + def __aiter__(self): + return self + + async def __anext__(self): + if self._yielded: + raise StopAsyncIteration + self._yielded = True + return object() + + +class _DepsAgentIter: + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + + async def __aenter__(self): + return _DepsAgentRun() + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _DepsAgent: + init_kwargs = None + iter_kwargs = None + + def __init__(self, **kwargs) -> None: + type(self).init_kwargs = kwargs + + def iter(self, *args, **kwargs): + type(self).iter_kwargs = kwargs + return _DepsAgentIter(**kwargs) + + +class _DepsWorker(ReActWorker): + def build_agent_deps(self, context: WorkerContext): + return {"execution_id": str(context.execution_id)} + + +def _minimal_task() -> BenchmarkTask: + return BenchmarkTask( + task_slug="unit-task", + instance_key="unit-instance", + description="Unit task", + task_payload=EmptyTaskPayload(), + ) + + +def _minimal_context() -> WorkerContext: + return WorkerContext( + run_id=UUID(int=3), + definition_id=UUID(int=4), + task_id=UUID(int=2), + execution_id=UUID(int=5), + sandbox_id="test-sandbox", + node_id=UUID(int=6), + ) + + +@pytest.mark.asyncio +async def test_react_worker_yields_partial_turn_before_reraising_agent_iter_failure( + monkeypatch, +) -> None: + monkeypatch.setattr(react_worker_module, "Agent", _FailingAgent) + monkeypatch.setattr( + react_worker_module, + "resolve_model_target", + lambda model: type( + "Resolved", + (), + {"model": "stub:constant", "capture_model_settings": None}, + )(), + ) + + worker = ReActWorker( + name="unit", + model=None, + task_id=UUID(int=1), + sandbox_id="test-sandbox", + tools=[], + system_prompt=None, + max_iterations=10, + ) + + turns = [] + with pytest.raises(RuntimeError, match="tool validation failed"): + async for turn in worker.execute(_minimal_task(), context=_minimal_context()): + turns.append(turn) + + assert len(turns) == 1 + assert any(part.content == "partial answer" for part in turns[0].response_parts) + + +@pytest.mark.asyncio +async def test_react_worker_passes_agent_deps_to_pydantic_ai(monkeypatch) -> None: + _DepsAgent.init_kwargs = None + _DepsAgent.iter_kwargs = None + monkeypatch.setattr(react_worker_module, "Agent", _DepsAgent) + monkeypatch.setattr( + react_worker_module, + "resolve_model_target", + lambda model: type( + "Resolved", + (), + {"model": "stub:constant", "capture_model_settings": None}, + )(), + ) + + worker = _DepsWorker( + name="unit", + model=None, + task_id=UUID(int=1), + sandbox_id="test-sandbox", + tools=[], + system_prompt=None, + max_iterations=10, + ) + + turns = [turn async for turn in worker.execute(_minimal_task(), context=_minimal_context())] - assert not hasattr(react_worker, "_build_turns") - assert not hasattr(react_worker, "_extract_request_parts") - assert not hasattr(react_worker, "_extract_response_parts") - assert not hasattr(react_worker, "_extract_tool_results") + assert len(turns) == 1 + assert _DepsAgent.init_kwargs["deps_type"] is dict + assert _DepsAgent.iter_kwargs["deps"] == {"execution_id": str(UUID(int=5))} diff --git a/uv.lock b/uv.lock index 26fdbdef..9a4fa791 100644 --- a/uv.lock +++ b/uv.lock @@ -1096,6 +1096,7 @@ version = "0.1.0" source = { editable = "ergon_builtins" } dependencies = [ { name = "ergon-core" }, + { name = "logfire" }, ] [package.optional-dependencies] @@ -1128,6 +1129,7 @@ requires-dist = [ { name = "ergon-builtins", extras = ["local-models", "data"], marker = "extra == 'all'", editable = "ergon_builtins" }, { name = "ergon-core", editable = "ergon_core" }, { name = "huggingface-hub", marker = "extra == 'data'" }, + { name = "logfire", specifier = ">=4.32.1" }, { name = "outlines", marker = "extra == 'local-models'" }, { name = "pandas", marker = "extra == 'data'" }, { name = "swebench", marker = "extra == 'data'", specifier = ">=3.0,<5" }, From 98e64e4ca8d7c5a3284ea495197c583f8ebefc75 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:23:04 +0100 Subject: [PATCH 17/66] test: keep OpenRouter budget helper in real LLM tests Move the rollout budget helper out of core so live-test spending controls stay scoped to the test harness. Made-with: Cursor --- docs/real-llm-rollout-harness.md | 2 +- tests/real_llm/benchmarks/test_researchrubrics.py | 2 +- tests/real_llm/fixtures/openrouter_budget.py | 3 ++- .../generation => tests/real_llm}/openrouter_budget.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) rename {ergon_core/ergon_core/core/providers/generation => tests/real_llm}/openrouter_budget.py (95%) diff --git a/docs/real-llm-rollout-harness.md b/docs/real-llm-rollout-harness.md index ad49aead..483ef622 100644 --- a/docs/real-llm-rollout-harness.md +++ b/docs/real-llm-rollout-harness.md @@ -45,7 +45,7 @@ Shipped (PR 1): flag, session fixtures wired. - `fixtures/stack.py` — docker-compose up/wait/down against the unified `docker-compose.yml`. -- `fixtures/openrouter_budget.py` + `ergon_core/.../openrouter_budget.py` +- `openrouter_budget.py` + `fixtures/openrouter_budget.py` — live spend check against `/api/v1/auth/key`. - `fixtures/harness_client.py` — polls `/api/test/read/run/{id}/state` for terminal status. diff --git a/tests/real_llm/benchmarks/test_researchrubrics.py b/tests/real_llm/benchmarks/test_researchrubrics.py index ee90c4be..ab7cb450 100644 --- a/tests/real_llm/benchmarks/test_researchrubrics.py +++ b/tests/real_llm/benchmarks/test_researchrubrics.py @@ -36,10 +36,10 @@ RunResource, RunTaskEvaluation, ) -from ergon_core.core.providers.generation.openrouter_budget import OpenRouterBudget from ergon_core.core.settings import settings from sqlmodel import select +from tests.real_llm.openrouter_budget import OpenRouterBudget from tests.real_llm.rollout import _fingerprint as fingerprint from tests.real_llm.rollout import ( capture_dashboard, diff --git a/tests/real_llm/fixtures/openrouter_budget.py b/tests/real_llm/fixtures/openrouter_budget.py index f107a08a..f38bf5ac 100644 --- a/tests/real_llm/fixtures/openrouter_budget.py +++ b/tests/real_llm/fixtures/openrouter_budget.py @@ -4,9 +4,10 @@ from collections.abc import AsyncGenerator import pytest -from ergon_core.core.providers.generation.openrouter_budget import OpenRouterBudget from ergon_core.core.settings import settings +from tests.real_llm.openrouter_budget import OpenRouterBudget + @pytest.fixture(scope="session") async def openrouter_budget() -> AsyncGenerator[OpenRouterBudget, None]: diff --git a/ergon_core/ergon_core/core/providers/generation/openrouter_budget.py b/tests/real_llm/openrouter_budget.py similarity index 95% rename from ergon_core/ergon_core/core/providers/generation/openrouter_budget.py rename to tests/real_llm/openrouter_budget.py index 35dcd3b5..fead99aa 100644 --- a/ergon_core/ergon_core/core/providers/generation/openrouter_budget.py +++ b/tests/real_llm/openrouter_budget.py @@ -1,4 +1,4 @@ -"""Track cumulative OpenRouter spend against a per-session budget. +"""Track cumulative OpenRouter spend against a per-session test budget. Usage: budget = OpenRouterBudget(limit_usd=5.0, api_key=os.environ["OPENROUTER_API_KEY"]) From 05fe26420d0bbb50288e8bb4a0652c71febe903a Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:23:21 +0100 Subject: [PATCH 18/66] docs: update OpenRouter budget helper references Point real-LLM harness notes at the test-local budget helper after the relocation. Made-with: Cursor --- docs/dead-code-audit-2026-04-25.md | 2 +- docs/rfcs/active/2026-04-21-real-llm-debug-harness.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/dead-code-audit-2026-04-25.md b/docs/dead-code-audit-2026-04-25.md index 8fa1c212..8551b54a 100644 --- a/docs/dead-code-audit-2026-04-25.md +++ b/docs/dead-code-audit-2026-04-25.md @@ -130,7 +130,7 @@ alternative control flow, not just unused helpers. | Area | File | Symbol / module | Current evidence | Decision | Why | Risk | Follow-up test/check | | --- | --- | --- | --- | --- | --- | --- | --- | | Core utils | `core/utils.py` | `get_mime_type` | No repo-wide caller. | Delete | Small unused helper. | Low | Search after deletion. | -| OpenRouter budget | `core/providers/generation/openrouter_budget.py` | `OpenRouterBudget` | Mostly referenced from tests/fixtures/benchmarks rather than active production modules. | Keep | Useful for real-LLM test budget gating. Not dead in the test harness context. | Low | None. | +| OpenRouter budget | `tests/real_llm/openrouter_budget.py` | `OpenRouterBudget` | Referenced from real-LLM fixtures/benchmarks rather than active production modules. | Keep test-local | Useful for real-LLM test budget gating. Not part of core runtime. | Low | None. | | Dashboard emitter | `core/dashboard/emitter.py` | `_RunContextEvent` import | Vulture flags unused import. | Delete | Straight unused import cleanup. | Low | Run lint/type check. | | RL extraction | `core/rl/extraction.py` | `add_special_tokens` parameter on `Tokenizer.encode()` protocol | Vulture flags it, but it is part of a `Protocol` signature matching common tokenizer APIs. Callers intentionally use bare `tokenizer.encode(...)`. | Keep | Static-analysis false positive. The parameter documents compatibility with tokenizer implementations such as Hugging Face tokenizers. | Low | If vulture noise matters, suppress/allowlist instead of deleting the protocol parameter. | diff --git a/docs/rfcs/active/2026-04-21-real-llm-debug-harness.md b/docs/rfcs/active/2026-04-21-real-llm-debug-harness.md index be0bcb98..89b7924a 100644 --- a/docs/rfcs/active/2026-04-21-real-llm-debug-harness.md +++ b/docs/rfcs/active/2026-04-21-real-llm-debug-harness.md @@ -89,7 +89,7 @@ tests/real_llm/ └── results_writer.py # per-run .results.md + PR body emission ergon_builtins/ergon_builtins/tools/benchmark_toolkit_composer.py # NEW -ergon_core/ergon_core/core/providers/generation/openrouter_budget.py # NEW +tests/real_llm/openrouter_budget.py # NEW docker-compose.real-llm.yml # NEW ``` @@ -136,7 +136,7 @@ in `ergon_cli/composition/__init__.py` wires this into ### OpenRouter budget gate ```python -# ergon_core/core/providers/generation/openrouter_budget.py +# tests/real_llm/openrouter_budget.py class OpenRouterBudget: def __init__(self, limit_usd: float) -> None: From 63f7f07a8b2e9fd7be7070f91c73850e627511d2 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:28:16 +0100 Subject: [PATCH 19/66] Consolidate graph status conventions Made-with: Cursor --- .../persistence/graph/status_conventions.py | 9 +++++++ .../runtime/services/task_inspection_dto.py | 15 ++--------- .../architecture/test_core_schema_sources.py | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 tests/unit/architecture/test_core_schema_sources.py diff --git a/ergon_core/ergon_core/core/persistence/graph/status_conventions.py b/ergon_core/ergon_core/core/persistence/graph/status_conventions.py index 00ffda6a..2829baf7 100644 --- a/ergon_core/ergon_core/core/persistence/graph/status_conventions.py +++ b/ergon_core/ergon_core/core/persistence/graph/status_conventions.py @@ -22,9 +22,18 @@ BLOCKED = "blocked" TERMINAL_STATUSES = frozenset({COMPLETED, FAILED, CANCELLED}) +NON_AUTONOMOUS_STATUSES = TERMINAL_STATUSES | frozenset({BLOCKED}) NodeStatus = Literal["pending", "ready", "running", "completed", "failed", "cancelled", "blocked"] + +def is_terminal_node_status(status: str) -> bool: + return status in TERMINAL_STATUSES + + +def is_blockable_node_status(status: str) -> bool: + return status != RUNNING and status not in TERMINAL_STATUSES + # ── Edge status ─────────────────────────────────────────────────── # Edges are pure dependency relations (containment lives on the node). # "active" is removed — delegation edges no longer exist. diff --git a/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py b/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py index 68e25940..72cdac77 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py @@ -1,20 +1,9 @@ """DTOs for TaskInspectionService — read-only subtask queries.""" -from typing import Literal - +from ergon_core.core.persistence.graph.status_conventions import NodeStatus from ergon_core.core.persistence.shared.types import NodeId from pydantic import BaseModel -SubtaskStatus = Literal[ - "pending", - "ready", - "running", - "completed", - "failed", - "blocked", - "cancelled", -] - class SubtaskInfo(BaseModel): """A snapshot of one subtask suitable for the manager to reason over.""" @@ -22,7 +11,7 @@ class SubtaskInfo(BaseModel): node_id: NodeId task_slug: str description: str - status: SubtaskStatus + status: NodeStatus depends_on: list[NodeId] output: str | None error: str | None diff --git a/tests/unit/architecture/test_core_schema_sources.py b/tests/unit/architecture/test_core_schema_sources.py new file mode 100644 index 00000000..b9bbc2dc --- /dev/null +++ b/tests/unit/architecture/test_core_schema_sources.py @@ -0,0 +1,26 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] + + +def test_graph_status_literals_are_defined_only_in_status_conventions() -> None: + offenders: list[str] = [] + duplicate_snippets = ( + 'Literal["pending", "ready", "running", "completed", "failed", "cancelled", "blocked"]', + 'Literal["pending", "ready", "running", "completed", "failed", "blocked", "cancelled"]', + 'Literal["pending", "satisfied", "invalidated"]', + ) + allowed = { + ROOT / "ergon_core/ergon_core/core/persistence/graph/status_conventions.py", + } + + for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): + if path in allowed: + continue + text = path.read_text() + compact_text = "".join(text.split()).replace(",]", "]") + for snippet in duplicate_snippets: + if snippet in text or "".join(snippet.split()) in compact_text: + offenders.append(f"{path.relative_to(ROOT)} duplicates {snippet}") + + assert offenders == [] From eedabec855ed15a83650fa9dfbaa68909816815d Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:32:51 +0100 Subject: [PATCH 20/66] Use graph status conventions in propagation Made-with: Cursor --- .../core/runtime/execution/propagation.py | 52 +++++++++---------- .../services/task_execution_service.py | 15 +++--- .../services/task_propagation_service.py | 14 ++--- .../workflow_initialization_service.py | 7 +-- .../runtime/test_propagation_contracts.py | 34 ++++++++++++ 5 files changed, 77 insertions(+), 45 deletions(-) create mode 100644 tests/unit/runtime/test_propagation_contracts.py diff --git a/ergon_core/ergon_core/core/runtime/execution/propagation.py b/ergon_core/ergon_core/core/runtime/execution/propagation.py index efa4aa02..a3b5dbdd 100644 --- a/ergon_core/ergon_core/core/runtime/execution/propagation.py +++ b/ergon_core/ergon_core/core/runtime/execution/propagation.py @@ -14,17 +14,8 @@ ExperimentDefinitionTask, ExperimentDefinitionTaskDependency, ) +from ergon_core.core.persistence.graph import status_conventions as graph_status from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode -from ergon_core.core.persistence.graph.status_conventions import ( - BLOCKED, - CANCELLED, - EDGE_INVALIDATED, - EDGE_SATISFIED, - FAILED, - RUNNING, - TERMINAL_STATUSES, -) -from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.runtime.services.graph_dto import MutationMeta from ergon_core.core.runtime.services.graph_lookup import GraphNodeLookup from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository @@ -75,7 +66,7 @@ async def mark_task_ready( session, run_id, task_id, - TaskExecutionStatus.PENDING, + graph_status.PENDING, graph_repo=graph_repo, graph_lookup=graph_lookup, ) @@ -94,7 +85,7 @@ async def mark_task_running( session, run_id, task_id, - TaskExecutionStatus.RUNNING, + graph_status.RUNNING, graph_repo=graph_repo, graph_lookup=graph_lookup, ) @@ -114,7 +105,7 @@ async def mark_task_failed( session, run_id, task_id, - TaskExecutionStatus.FAILED, + graph_status.FAILED, graph_repo=graph_repo, graph_lookup=graph_lookup, event_metadata={"error": error}, @@ -178,7 +169,7 @@ async def mark_task_failed_by_node( session, run_id=run_id, node_id=node_id, - new_status=TaskExecutionStatus.FAILED, + new_status=graph_status.FAILED, meta=MutationMeta( actor="system:propagation", reason=error, @@ -215,16 +206,16 @@ async def _block_successors_bfs( target_node = session.get(RunGraphNode, target_id) if target_node is None: continue - if target_node.status == RUNNING: + if target_node.status == graph_status.RUNNING: continue - if target_node.status in TERMINAL_STATUSES: + if target_node.status in graph_status.TERMINAL_STATUSES: continue applied = await graph_repo.update_node_status( session, run_id=run_id, node_id=target_id, - new_status=BLOCKED, + new_status=graph_status.BLOCKED, meta=MutationMeta( actor="system:propagation", reason=f"dependency {failed_node_id} {terminal_status}", @@ -246,7 +237,7 @@ async def _block_successors_bfs( session, run_id=run_id, edge_id=edge.id, - new_status=EDGE_INVALIDATED, + new_status=graph_status.EDGE_INVALIDATED, meta=_PROPAGATION_META, ) queue.append(edge.target_node_id) @@ -279,7 +270,7 @@ async def on_task_completed_or_failed( before calling this function. The node's own status is NOT written here — only edge statuses and downstream candidate statuses are updated. """ - is_success = terminal_status == TaskExecutionStatus.COMPLETED + is_success = terminal_status == graph_status.COMPLETED outgoing = list( session.exec( @@ -290,7 +281,7 @@ async def on_task_completed_or_failed( ).all() ) - edge_status = EDGE_SATISFIED if is_success else EDGE_INVALIDATED + edge_status = graph_status.EDGE_SATISFIED if is_success else graph_status.EDGE_INVALIDATED for edge in outgoing: await graph_repo.update_edge_status( session, @@ -322,7 +313,10 @@ async def on_task_completed_or_failed( candidate_node = session.get(RunGraphNode, candidate_id) if candidate_node is None: continue - if candidate_node.status in TERMINAL_STATUSES and candidate_node.status != CANCELLED: + if ( + candidate_node.status in graph_status.TERMINAL_STATUSES + and candidate_node.status != graph_status.CANCELLED + ): continue # Eligibility: @@ -341,8 +335,8 @@ async def on_task_completed_or_failed( # Everything else (COMPLETED, FAILED, RUNNING, BLOCKED) is skipped. status = candidate_node.status is_managed_subtask = candidate_node.parent_node_id is not None - is_pending = status == TaskExecutionStatus.PENDING - is_reactivatable_cancelled = status == CANCELLED and is_managed_subtask + is_pending = status == graph_status.PENDING + is_reactivatable_cancelled = status == graph_status.CANCELLED and is_managed_subtask if not (is_pending or is_reactivatable_cancelled): continue @@ -357,7 +351,7 @@ async def on_task_completed_or_failed( ) source_nodes = [session.get(RunGraphNode, e.source_node_id) for e in incoming] - if all(n is not None and n.status == TaskExecutionStatus.COMPLETED for n in source_nodes): + if all(n is not None and n.status == graph_status.COMPLETED for n in source_nodes): reason = ( f"all dependencies satisfied after {node_id}" if is_pending @@ -367,7 +361,7 @@ async def on_task_completed_or_failed( session, run_id=run_id, node_id=candidate_id, - new_status=TaskExecutionStatus.PENDING, + new_status=graph_status.PENDING, meta=MutationMeta( actor="system:propagation", reason=reason, @@ -395,10 +389,12 @@ def is_workflow_complete_v2(session: Session, run_id: UUID) -> bool: ) if not statuses: return True - return all(s in TERMINAL_STATUSES for s in statuses) and not any(s == FAILED for s in statuses) + return all(s in graph_status.TERMINAL_STATUSES for s in statuses) and not any( + s == graph_status.FAILED for s in statuses + ) -_SETTLED_STATUSES = TERMINAL_STATUSES | frozenset({BLOCKED}) +_SETTLED_STATUSES = graph_status.TERMINAL_STATUSES | frozenset({graph_status.BLOCKED}) def is_workflow_failed_v2(session: Session, run_id: UUID) -> bool: @@ -419,4 +415,4 @@ def is_workflow_failed_v2(session: Session, run_id: UUID) -> bool: if not statuses: return False all_settled = all(s in _SETTLED_STATUSES for s in statuses) - return all_settled and any(s == FAILED for s in statuses) + return all_settled and any(s == graph_status.FAILED for s in statuses) diff --git a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py b/ergon_core/ergon_core/core/runtime/services/task_execution_service.py index 62e3c168..075f3c33 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_execution_service.py @@ -10,6 +10,7 @@ ExperimentDefinitionTaskAssignment, ExperimentDefinitionWorker, ) +from ergon_core.core.persistence.graph import status_conventions as graph_status from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus @@ -140,7 +141,7 @@ async def _prepare_graph_native( session, run_id=command.run_id, node_id=node_id, - new_status=TaskExecutionStatus.RUNNING, + new_status=graph_status.RUNNING, meta=MutationMeta( actor="task-execution-service", reason=f"prepare: execution {execution.id}", @@ -152,7 +153,7 @@ async def _prepare_graph_native( run_id=command.run_id, node_id=node_id, task_slug=node.task_slug, - new_status=TaskExecutionStatus.RUNNING, + new_status=graph_status.RUNNING, old_status=None, worker_id=worker_row.id, worker_name=assigned_worker_slug, @@ -272,7 +273,7 @@ async def _prepare_definition( run_id=command.run_id, node_id=resolved_node_id, task_slug=task.task_slug, - new_status=TaskExecutionStatus.RUNNING, + new_status=graph_status.RUNNING, old_status=None, worker_id=definition_worker_id, worker_name=assigned_worker_slug, @@ -318,8 +319,8 @@ async def finalize_success(self, command: FinalizeTaskExecutionCommand) -> None: run_id=execution.run_id, node_id=execution.node_id, task_slug=str(execution.definition_task_id or execution.node_id or ""), - new_status=TaskExecutionStatus.COMPLETED, - old_status=TaskExecutionStatus.RUNNING, + new_status=graph_status.COMPLETED, + old_status=graph_status.RUNNING, ) async def finalize_failure(self, command: FailTaskExecutionCommand) -> None: @@ -360,8 +361,8 @@ async def finalize_failure(self, command: FailTaskExecutionCommand) -> None: run_id=command.run_id, node_id=execution.node_id, task_slug=str(execution.definition_task_id or execution.node_id or ""), - new_status=TaskExecutionStatus.FAILED, - old_status=TaskExecutionStatus.RUNNING, + new_status=graph_status.FAILED, + old_status=graph_status.RUNNING, ) # -- Helpers --- diff --git a/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py b/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py index c913dd9b..3149bec0 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py @@ -2,9 +2,9 @@ from uuid import UUID +from ergon_core.core.persistence.graph import status_conventions as graph_status from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.runtime.execution.propagation import ( is_workflow_complete_v2, is_workflow_failed_v2, @@ -58,7 +58,7 @@ async def propagate(self, command: PropagateTaskCompletionCommand) -> Propagatio session, run_id=command.run_id, node_id=node_id, - new_status=TaskExecutionStatus.COMPLETED, + new_status=graph_status.COMPLETED, meta=MutationMeta( actor="system:propagation", reason=f"task {command.task_id} completed", @@ -70,7 +70,7 @@ async def propagate(self, command: PropagateTaskCompletionCommand) -> Propagatio session, command.run_id, node_id, - TaskExecutionStatus.COMPLETED, + graph_status.COMPLETED, graph_repo=graph_repo, ) @@ -114,7 +114,7 @@ async def operator_unblock(self, *, run_id: UUID, node_id: UUID, reason: str) -> session, run_id=run_id, node_id=node_id, - new_status=TaskExecutionStatus.PENDING, + new_status=graph_status.PENDING, meta=MutationMeta(actor="operator:unblock", reason=reason), ) session.commit() @@ -131,7 +131,7 @@ async def restart_node(self, *, run_id: UUID, node_id: UUID, reason: str) -> Non session, run_id=run_id, node_id=node_id, - new_status=TaskExecutionStatus.PENDING, + new_status=graph_status.PENDING, meta=MutationMeta(actor="operator:restart", reason=reason), ) session.commit() @@ -157,7 +157,7 @@ async def propagate_failure(self, command: PropagateTaskCompletionCommand) -> Pr session, run_id=command.run_id, node_id=node_id, - new_status=TaskExecutionStatus.FAILED, + new_status=graph_status.FAILED, meta=MutationMeta( actor="system:propagation", reason=f"task {command.task_id} failed", @@ -169,7 +169,7 @@ async def propagate_failure(self, command: PropagateTaskCompletionCommand) -> Pr session, command.run_id, node_id, - TaskExecutionStatus.FAILED, + graph_status.FAILED, graph_repo=graph_repo, ) diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py b/ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py index 28f09284..9bb726f5 100644 --- a/ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py +++ b/ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py @@ -5,8 +5,9 @@ ExperimentDefinition, ExperimentDefinitionTask, ) +from ergon_core.core.persistence.graph import status_conventions as graph_status from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus +from ergon_core.core.persistence.shared.enums import RunStatus from ergon_core.core.persistence.telemetry.models import RunRecord from ergon_core.core.runtime.execution.propagation import get_initial_ready_tasks from ergon_core.core.runtime.services.graph_dto import MutationMeta @@ -52,8 +53,8 @@ async def initialize(self, command: InitializeWorkflowCommand) -> InitializedWor session, command.run_id, command.definition_id, - initial_node_status=TaskExecutionStatus.PENDING, - initial_edge_status="pending", + initial_node_status=graph_status.PENDING, + initial_edge_status=graph_status.EDGE_PENDING, task_payload_model=benchmark_cls.task_payload_model, meta=MutationMeta(actor="system:workflow_init"), ) diff --git a/tests/unit/runtime/test_propagation_contracts.py b/tests/unit/runtime/test_propagation_contracts.py new file mode 100644 index 00000000..c8ced804 --- /dev/null +++ b/tests/unit/runtime/test_propagation_contracts.py @@ -0,0 +1,34 @@ +from ergon_core.core.persistence.graph import status_conventions as graph_status +from ergon_core.core.runtime.execution import propagation +from ergon_core.core.runtime.services import task_execution_service, task_propagation_service +from ergon_core.core.runtime.services import workflow_initialization_service + + +def _source(module: object) -> str: + loader = getattr(module, "__loader__") + source = loader.get_source(module.__name__) + assert source is not None + return source + + +def test_graph_writers_do_not_use_task_execution_status_for_node_status() -> None: + modules = [ + propagation, + task_execution_service, + task_propagation_service, + workflow_initialization_service, + ] + forbidden_snippets = ( + "new_status=TaskExecutionStatus.", + "initial_node_status=TaskExecutionStatus.", + ) + + offenders = [ + f"{module.__name__}: {snippet}" + for module in modules + for snippet in forbidden_snippets + if snippet in _source(module) + ] + + assert offenders == [] + assert graph_status.READY == "ready" From 9beec0fc6ca57e0640ff8d07d4518e55d3e9c9d3 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:35:37 +0100 Subject: [PATCH 21/66] Align propagation contract with blocked successors Made-with: Cursor --- .../core/runtime/execution/propagation.py | 21 +++++++----------- .../runtime/inngest/propagate_execution.py | 16 -------------- .../runtime/services/orchestration_dto.py | 1 - .../services/task_propagation_service.py | 22 +++++++++---------- .../runtime/test_propagation_contracts.py | 5 +++++ 5 files changed, 23 insertions(+), 42 deletions(-) diff --git a/ergon_core/ergon_core/core/runtime/execution/propagation.py b/ergon_core/ergon_core/core/runtime/execution/propagation.py index a3b5dbdd..ba7f3745 100644 --- a/ergon_core/ergon_core/core/runtime/execution/propagation.py +++ b/ergon_core/ergon_core/core/runtime/execution/propagation.py @@ -250,19 +250,15 @@ async def on_task_completed_or_failed( terminal_status: str, *, graph_repo: WorkflowGraphRepository, -) -> tuple[list[UUID], list[UUID]]: +) -> list[UUID]: """Handle a node reaching COMPLETED, FAILED, or CANCELLED. - Returns (newly_ready_node_ids, invalidated_target_node_ids). + Returns newly ready node IDs. - - COMPLETED: outgoing edges become SATISFIED; targets with all deps - satisfied become READY. - - FAILED / CANCELLED: outgoing edges become INVALIDATED. For static - workflow nodes (parent_node_id is None), targets are auto-cancelled - and reported as invalidated. For dynamic subtasks (parent_node_id - set), targets stay PENDING so the manager can adapt — the edge is - invalidated but the node is left for the manager to retry, cancel, - or re-plan via the subtask lifecycle tools. + - COMPLETED: outgoing edges become SATISFIED; targets with all dependencies + satisfied transition to PENDING for scheduling. + - FAILED / CANCELLED: outgoing edges become INVALIDATED; reachable successors + transition to BLOCKED unless they are RUNNING or terminal. Walks RunGraphEdge so it works for both static and dynamic tasks. @@ -294,7 +290,6 @@ async def on_task_completed_or_failed( candidate_node_ids = {e.target_node_id for e in outgoing} newly_ready: list[UUID] = [] - invalidated: list[UUID] = [] if not is_success: await _block_successors_bfs( @@ -306,7 +301,7 @@ async def on_task_completed_or_failed( graph_repo=graph_repo, ) session.commit() - return newly_ready, invalidated + return newly_ready # SUCCESS PATH: source completed — check if candidates can become READY. for candidate_id in candidate_node_ids: @@ -374,7 +369,7 @@ async def on_task_completed_or_failed( newly_ready.append(candidate_id) session.commit() - return newly_ready, invalidated + return newly_ready # --------------------------------------------------------------------------- diff --git a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py b/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py index 518c001b..1b59bdb1 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py +++ b/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py @@ -9,7 +9,6 @@ import inngest from ergon_core.core.providers.sandbox.lifecycle import terminate_sandbox_by_id from ergon_core.core.runtime.events.task_events import ( - TaskCancelledEvent, TaskCompletedEvent, TaskFailedEvent, TaskReadyEvent, @@ -70,20 +69,6 @@ async def propagate_task_fn(ctx: inngest.Context) -> TaskPropagateResult: for td in propagation.ready_tasks ] - for inv_node_id in propagation.invalidated_targets: - events.append( - inngest.Event( - name=TaskCancelledEvent.name, - data=TaskCancelledEvent( - run_id=payload.run_id, - definition_id=payload.definition_id, - node_id=inv_node_id, - execution_id=None, - cause="dep_invalidated", - ).model_dump(mode="json"), - ) - ) - if propagation.workflow_terminal_state == WorkflowTerminalState.COMPLETED: events.append( inngest.Event( @@ -164,7 +149,6 @@ async def propagate_task_failure_fn(ctx: inngest.Context) -> TaskPropagateResult await _terminate_failed_task_sandbox(payload.sandbox_id) # BLOCKED successors are a DB write only — no task/cancelled events. - # propagation.invalidated_targets is always empty from the failure path. failure_events: list[inngest.Event] = [] if propagation.workflow_terminal_state == WorkflowTerminalState.FAILED: diff --git a/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py b/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py index 438bd25a..400d04f2 100644 --- a/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py @@ -133,7 +133,6 @@ class PropagationResult(BaseModel): definition_id: UUID completed_task_id: UUID | None ready_tasks: list[TaskDescriptor] = Field(default_factory=list) - invalidated_targets: list[UUID] = Field(default_factory=list) workflow_terminal_state: WorkflowTerminalState = WorkflowTerminalState.NONE diff --git a/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py b/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py index 3149bec0..653fb57c 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py @@ -30,11 +30,11 @@ class TaskPropagationService: """ async def propagate(self, command: PropagateTaskCompletionCommand) -> PropagationResult: - """Handle successful task completion: satisfy deps, cascade invalidations. + """Handle successful task completion: satisfy deps and schedule ready tasks. - Returns newly-ready tasks (for scheduling) and invalidated targets - (for emitting task/cancelled events). Uses the graph-native v2 path - which reads stored containment columns rather than edge traversal. + Returns newly-ready tasks for scheduling. Failure propagation blocks + downstream graph nodes in the database and does not emit cancellation + events from this contract. """ with get_session() as session: graph_repo = WorkflowGraphRepository() @@ -66,7 +66,7 @@ async def propagate(self, command: PropagateTaskCompletionCommand) -> Propagatio only_if_not_terminal=True, ) - newly_ready_node_ids, invalidated_node_ids = await on_task_completed_or_failed( + newly_ready_node_ids = await on_task_completed_or_failed( session, command.run_id, node_id, @@ -97,7 +97,6 @@ async def propagate(self, command: PropagateTaskCompletionCommand) -> Propagatio definition_id=command.definition_id, completed_task_id=command.task_id, ready_tasks=ready_descriptors, - invalidated_targets=invalidated_node_ids, workflow_terminal_state=terminal, ) @@ -137,10 +136,11 @@ async def restart_node(self, *, run_id: UUID, node_id: UUID, reason: str) -> Non session.commit() async def propagate_failure(self, command: PropagateTaskCompletionCommand) -> PropagationResult: - """Handle task failure: invalidate downstream deps, detect workflow terminal. + """Handle task failure: block downstream graph nodes, detect workflow terminal. - Unlike propagate(), never produces newly-ready tasks — a failed source - only invalidates outgoing edges and marks targets CANCELLED. + Unlike propagate(), never produces newly-ready tasks. A failed source + invalidates outgoing edges and transitions reachable successors to + BLOCKED unless they are RUNNING or terminal. """ with get_session() as session: graph_repo = WorkflowGraphRepository() @@ -150,7 +150,6 @@ async def propagate_failure(self, command: PropagateTaskCompletionCommand) -> Pr graph_lookup = GraphNodeLookup(session, command.run_id) node_id = graph_lookup.node_id(command.task_id) - invalidated_node_ids: list[UUID] = [] if node_id is not None: # Mark the triggering node as FAILED before propagating edges. await graph_repo.update_node_status( @@ -165,7 +164,7 @@ async def propagate_failure(self, command: PropagateTaskCompletionCommand) -> Pr only_if_not_terminal=True, ) - _ready, invalidated_node_ids = await on_task_completed_or_failed( + await on_task_completed_or_failed( session, command.run_id, node_id, @@ -181,6 +180,5 @@ async def propagate_failure(self, command: PropagateTaskCompletionCommand) -> Pr run_id=command.run_id, definition_id=command.definition_id, completed_task_id=command.task_id, - invalidated_targets=invalidated_node_ids, workflow_terminal_state=terminal, ) diff --git a/tests/unit/runtime/test_propagation_contracts.py b/tests/unit/runtime/test_propagation_contracts.py index c8ced804..a444da09 100644 --- a/tests/unit/runtime/test_propagation_contracts.py +++ b/tests/unit/runtime/test_propagation_contracts.py @@ -2,6 +2,7 @@ from ergon_core.core.runtime.execution import propagation from ergon_core.core.runtime.services import task_execution_service, task_propagation_service from ergon_core.core.runtime.services import workflow_initialization_service +from ergon_core.core.runtime.services.orchestration_dto import PropagationResult def _source(module: object) -> str: @@ -32,3 +33,7 @@ def test_graph_writers_do_not_use_task_execution_status_for_node_status() -> Non assert offenders == [] assert graph_status.READY == "ready" + + +def test_propagation_result_does_not_expose_invalidated_targets() -> None: + assert "invalidated_targets" not in PropagationResult.model_fields From 0d8facb0c153f552aae8ae3f930bd35c6b0251a2 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:37:39 +0100 Subject: [PATCH 22/66] Use canonical evaluation criterion status Made-with: Cursor --- ergon_core/ergon_core/core/api/schemas.py | 5 ++--- .../architecture/test_core_schema_sources.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ergon_core/ergon_core/core/api/schemas.py b/ergon_core/ergon_core/core/api/schemas.py index 4e3f6be5..373e5323 100644 --- a/ergon_core/ergon_core/core/api/schemas.py +++ b/ergon_core/ergon_core/core/api/schemas.py @@ -6,13 +6,12 @@ """ from datetime import datetime -from typing import Any, Literal +from typing import Any +from ergon_core.core.persistence.telemetry.evaluation_summary import EvalCriterionStatus from ergon_core.core.runtime.services.graph_dto import GraphMutationValue from pydantic import BaseModel, ConfigDict, Field -EvalCriterionStatus = Literal["passed", "failed", "errored", "skipped"] - def _to_camel(value: str) -> str: head, *tail = value.split("_") diff --git a/tests/unit/architecture/test_core_schema_sources.py b/tests/unit/architecture/test_core_schema_sources.py index b9bbc2dc..58f1e8b3 100644 --- a/tests/unit/architecture/test_core_schema_sources.py +++ b/tests/unit/architecture/test_core_schema_sources.py @@ -24,3 +24,20 @@ def test_graph_status_literals_are_defined_only_in_status_conventions() -> None: offenders.append(f"{path.relative_to(ROOT)} duplicates {snippet}") assert offenders == [] + + +def test_eval_criterion_status_literal_is_defined_only_in_evaluation_summary() -> None: + offenders: list[str] = [] + snippet = 'EvalCriterionStatus=Literal["passed","failed","errored","skipped"]' + allowed = { + ROOT / "ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py", + } + + for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): + if path in allowed: + continue + compact_text = "".join(path.read_text().split()).replace(",]", "]") + if snippet in compact_text: + offenders.append(str(path.relative_to(ROOT))) + + assert offenders == [] From c0f3ebafdeffc935188ff9d332e14e3ecf530a15 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:41:43 +0100 Subject: [PATCH 23/66] Unify graph mutation payload contracts Made-with: Cursor --- ergon_core/ergon_core/core/api/runs.py | 6 +-- ergon_core/ergon_core/core/api/schemas.py | 27 +---------- .../ergon_core/core/dashboard/emitter.py | 31 +++++-------- .../core/dashboard/event_contracts.py | 14 +----- .../core/runtime/services/graph_dto.py | 14 +++--- .../core/runtime/services/graph_repository.py | 27 +++++------ .../core/runtime/services/run_read_service.py | 14 +++--- .../runtime/test_graph_mutation_contracts.py | 45 +++++++++++++++++++ 8 files changed, 92 insertions(+), 86 deletions(-) create mode 100644 tests/unit/runtime/test_graph_mutation_contracts.py diff --git a/ergon_core/ergon_core/core/api/runs.py b/ergon_core/ergon_core/core/api/runs.py index 2696cbfd..16712436 100644 --- a/ergon_core/ergon_core/core/api/runs.py +++ b/ergon_core/ergon_core/core/api/runs.py @@ -11,7 +11,6 @@ RunContextEventDto, RunEvaluationCriterionDto, RunExecutionAttemptDto, - RunGraphMutationDto, RunResourceDto, RunSandboxCommandDto, RunSandboxDto, @@ -39,6 +38,7 @@ TrainingMetric, TrainingSession, ) +from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto from ergon_core.core.runtime.services.run_read_service import RunReadService from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse @@ -424,8 +424,8 @@ def get_run(run_id: UUID) -> RunSnapshotDto: # --------------------------------------------------------------------------- -@router.get("/{run_id}/mutations", response_model=list[RunGraphMutationDto]) -def get_mutations(run_id: UUID) -> list[RunGraphMutationDto]: +@router.get("/{run_id}/mutations", response_model=list[GraphMutationRecordDto]) +def get_mutations(run_id: UUID) -> list[GraphMutationRecordDto]: """Return the append-only mutation log for a run, ordered by sequence. Used by the Timeline scrubber to replay DAG state at any point in time. diff --git a/ergon_core/ergon_core/core/api/schemas.py b/ergon_core/ergon_core/core/api/schemas.py index 373e5323..481a562c 100644 --- a/ergon_core/ergon_core/core/api/schemas.py +++ b/ergon_core/ergon_core/core/api/schemas.py @@ -9,7 +9,7 @@ from typing import Any from ergon_core.core.persistence.telemetry.evaluation_summary import EvalCriterionStatus -from ergon_core.core.runtime.services.graph_dto import GraphMutationValue +from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto from pydantic import BaseModel, ConfigDict, Field @@ -237,28 +237,3 @@ class TrainingMetricDto(CamelModel): step_time_s: float | None = None -# --------------------------------------------------------------------------- -# Run graph mutation DTO (Timeline scrubber) -# --------------------------------------------------------------------------- - - -class RunGraphMutationDto(BaseModel): - """One entry in the append-only mutation log for a run. - - Field names are snake_case to match the frontend GraphMutationDtoSchema. - CamelModel is intentionally not used here — the frontend contract uses snake_case. - """ - - model_config = ConfigDict(extra="forbid") - - id: str - run_id: str - sequence: int - mutation_type: str - target_type: str - target_id: str - actor: str - old_value: GraphMutationValue | None - new_value: GraphMutationValue - reason: str | None - created_at: str diff --git a/ergon_core/ergon_core/core/dashboard/emitter.py b/ergon_core/ergon_core/core/dashboard/emitter.py index cd91203d..e9ca438e 100644 --- a/ergon_core/ergon_core/core/dashboard/emitter.py +++ b/ergon_core/ergon_core/core/dashboard/emitter.py @@ -19,8 +19,6 @@ _PAYLOAD_ADAPTER, ) from ergon_core.core.persistence.graph.models import ( - GraphTargetType, - MutationType, RunGraphMutation, ) from ergon_core.core.persistence.queries import queries @@ -31,9 +29,8 @@ from ergon_core.core.runtime.services.cohort_stats_service import ( experiment_cohort_stats_service, ) -from ergon_core.core.runtime.services.graph_dto import GraphMutationValue +from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto from ergon_core.core.utils import utcnow -from pydantic import TypeAdapter if TYPE_CHECKING: from ergon_core.core.persistence.context.models import RunContextEvent @@ -54,8 +51,6 @@ TaskTreeNode, ) -_MUTATION_VALUE_ADAPTER: TypeAdapter[GraphMutationValue] = TypeAdapter(GraphMutationValue) - logger = logging.getLogger(__name__) @@ -365,25 +360,21 @@ async def graph_mutation(self, row: RunGraphMutation) -> None: if not self._enabled: return try: - raw_new = {"mutation_type": row.mutation_type, **row.new_value} - new_value = _MUTATION_VALUE_ADAPTER.validate_python(raw_new) - - old_value: GraphMutationValue | None = None - if row.old_value: - raw_old = {"mutation_type": row.mutation_type, **row.old_value} - old_value = _MUTATION_VALUE_ADAPTER.validate_python(raw_old) - - evt = DashboardGraphMutationEvent( + record = GraphMutationRecordDto( + id=row.id, run_id=row.run_id, sequence=row.sequence, - mutation_type=cast(MutationType, row.mutation_type), - target_type=cast(GraphTargetType, row.target_type), + mutation_type=row.mutation_type, + target_type=row.target_type, target_id=row.target_id, actor=row.actor, - new_value=new_value, - old_value=old_value, + old_value=dict(row.old_value) if row.old_value else None, + new_value=dict(row.new_value), reason=row.reason, - timestamp=row.created_at, + created_at=row.created_at, + ) + evt = DashboardGraphMutationEvent( + mutation=record, ) await inngest_client.send( inngest.Event(name=evt.name, data=evt.model_dump(mode="json")) diff --git a/ergon_core/ergon_core/core/dashboard/event_contracts.py b/ergon_core/ergon_core/core/dashboard/event_contracts.py index 2f7f0ccb..4ed4bda7 100644 --- a/ergon_core/ergon_core/core/dashboard/event_contracts.py +++ b/ergon_core/ergon_core/core/dashboard/event_contracts.py @@ -20,10 +20,9 @@ ContextEventPayload, ContextEventType, ) -from ergon_core.core.persistence.graph.models import GraphTargetType, MutationType from ergon_core.core.runtime.events.base import InngestEventContract from ergon_core.core.runtime.services.cohort_schemas import CohortSummaryDto -from ergon_core.core.runtime.services.graph_dto import GraphMutationValue +from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto from pydantic import BaseModel, Field # --------------------------------------------------------------------------- @@ -218,16 +217,7 @@ class CohortUpdatedEvent(InngestEventContract): class DashboardGraphMutationEvent(InngestEventContract): name: ClassVar[str] = "dashboard/graph.mutation" - run_id: UUID - sequence: int - mutation_type: MutationType - target_type: GraphTargetType - target_id: UUID - actor: str - new_value: GraphMutationValue - old_value: GraphMutationValue | None = None - reason: str | None = None - timestamp: datetime + mutation: GraphMutationRecordDto class DashboardContextEventEvent(InngestEventContract): diff --git a/ergon_core/ergon_core/core/runtime/services/graph_dto.py b/ergon_core/ergon_core/core/runtime/services/graph_dto.py index 80130bdb..92f5b961 100644 --- a/ergon_core/ergon_core/core/runtime/services/graph_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/graph_dto.py @@ -8,6 +8,7 @@ serialization cost). """ +from datetime import datetime from typing import Annotated, Literal from uuid import UUID @@ -89,7 +90,9 @@ class GraphAnnotationDto(BaseModel): payload: JsonObject -class GraphMutationDto(BaseModel): +class GraphMutationRecordDto(BaseModel): + """Append-only graph mutation record with a typed mutation payload.""" + model_config = {"frozen": True} id: UUID = Field(description="Identifier of the mutation row itself, not a graph target id.") @@ -107,6 +110,7 @@ class GraphMutationDto(BaseModel): old_value: "GraphMutationValue | None" new_value: "GraphMutationValue" reason: str | None + created_at: datetime class WorkflowGraphDto(BaseModel): @@ -175,8 +179,8 @@ class EdgeAddedMutation(BaseModel): model_config = {"frozen": True} mutation_type: Literal["edge.added"] = "edge.added" - source_node_id: str - target_node_id: str + source_node_id: NodeId + target_node_id: NodeId status: str @@ -186,8 +190,8 @@ class EdgeRemovedMutation(BaseModel): model_config = {"frozen": True} mutation_type: Literal["edge.removed"] = "edge.removed" - source_node_id: str - target_node_id: str + source_node_id: NodeId + target_node_id: NodeId status: str diff --git a/ergon_core/ergon_core/core/runtime/services/graph_repository.py b/ergon_core/ergon_core/core/runtime/services/graph_repository.py index 1009d2ae..b0b797fd 100644 --- a/ergon_core/ergon_core/core/runtime/services/graph_repository.py +++ b/ergon_core/ergon_core/core/runtime/services/graph_repository.py @@ -42,7 +42,7 @@ EdgeStatusChangedMutation, GraphAnnotationDto, GraphEdgeDto, - GraphMutationDto, + GraphMutationRecordDto, GraphMutationValue, GraphNodeDto, MutationMeta, @@ -200,7 +200,7 @@ def initialize_from_definition( target_id=node.id, actor=meta.actor, old_value=None, - new_value=_node_snapshot(node).model_dump(), + new_value=_node_snapshot(node).model_dump(mode="json"), reason=meta.reason, created_at=now, ) @@ -232,7 +232,7 @@ def initialize_from_definition( new_value=AnnotationSetMutation( namespace="payload", payload=payload, - ).model_dump(), + ).model_dump(mode="json"), reason=meta.reason, created_at=now, ) @@ -249,7 +249,7 @@ def initialize_from_definition( target_id=edge.id, actor=meta.actor, old_value=None, - new_value=_edge_snapshot(edge).model_dump(), + new_value=_edge_snapshot(edge).model_dump(mode="json"), reason=meta.reason, created_at=now, ) @@ -782,7 +782,7 @@ def get_mutations( run_id: UUID, *, since_sequence: int = 0, - ) -> list[GraphMutationDto]: + ) -> list[GraphMutationRecordDto]: rows = list( session.exec( select(RunGraphMutation) @@ -884,8 +884,8 @@ async def _log_mutation( target_type=target_type, target_id=target_id, actor=meta.actor, - old_value=old_value.model_dump() if old_value is not None else None, - new_value=new_value.model_dump(), + old_value=old_value.model_dump(mode="json") if old_value is not None else None, + new_value=new_value.model_dump(mode="json"), reason=meta.reason, created_at=utcnow(), ) @@ -967,8 +967,8 @@ def _to_annotation_dto(row: RunGraphAnnotation) -> GraphAnnotationDto: ) -def _to_mutation_dto(row: RunGraphMutation) -> GraphMutationDto: - return GraphMutationDto( +def _to_mutation_dto(row: RunGraphMutation) -> GraphMutationRecordDto: + return GraphMutationRecordDto( id=row.id, run_id=row.run_id, sequence=row.sequence, @@ -979,6 +979,7 @@ def _to_mutation_dto(row: RunGraphMutation) -> GraphMutationDto: old_value=dict(row.old_value) if row.old_value else None, new_value=dict(row.new_value), reason=row.reason, + created_at=row.created_at, ) @@ -994,8 +995,8 @@ def _node_removed_snapshot(node: RunGraphNode) -> NodeRemovedMutation: def _edge_removed_snapshot(edge: RunGraphEdge) -> EdgeRemovedMutation: return EdgeRemovedMutation( - source_node_id=str(edge.source_node_id), - target_node_id=str(edge.target_node_id), + source_node_id=edge.source_node_id, + target_node_id=edge.target_node_id, status=edge.status, ) @@ -1012,8 +1013,8 @@ def _node_snapshot(node: RunGraphNode) -> NodeAddedMutation: def _edge_snapshot(edge: RunGraphEdge) -> EdgeAddedMutation: return EdgeAddedMutation( - source_node_id=str(edge.source_node_id), - target_node_id=str(edge.target_node_id), + source_node_id=edge.source_node_id, + target_node_id=edge.target_node_id, status=edge.status, ) diff --git a/ergon_core/ergon_core/core/runtime/services/run_read_service.py b/ergon_core/ergon_core/core/runtime/services/run_read_service.py index f8a2b811..7af38c25 100644 --- a/ergon_core/ergon_core/core/runtime/services/run_read_service.py +++ b/ergon_core/ergon_core/core/runtime/services/run_read_service.py @@ -7,7 +7,6 @@ from uuid import UUID from ergon_core.core.api.schemas import ( - RunGraphMutationDto, RunSnapshotDto, TrainingCurvePointDto, TrainingMetricDto, @@ -30,6 +29,7 @@ TrainingMetric, TrainingSession, ) +from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto from pydantic import BaseModel from sqlmodel import select @@ -182,7 +182,7 @@ def build_run_snapshot(self, run_id: UUID) -> RunSnapshotDto | None: error=run.error_message, ) - def list_mutations(self, run_id: UUID) -> list[RunGraphMutationDto] | None: + def list_mutations(self, run_id: UUID) -> list[GraphMutationRecordDto] | None: with get_session() as session: run = session.get(RunRecord, run_id) if run is None: @@ -196,18 +196,18 @@ def list_mutations(self, run_id: UUID) -> list[RunGraphMutationDto] | None: ) return [ - RunGraphMutationDto( - id=str(m.id), - run_id=str(m.run_id), + GraphMutationRecordDto( + id=m.id, + run_id=m.run_id, sequence=m.sequence, mutation_type=m.mutation_type, target_type=m.target_type, - target_id=str(m.target_id), + target_id=m.target_id, actor=m.actor, old_value=m.old_value, new_value=m.new_value, reason=m.reason, - created_at=m.created_at.isoformat(), + created_at=m.created_at, ) for m in mutations ] diff --git a/tests/unit/runtime/test_graph_mutation_contracts.py b/tests/unit/runtime/test_graph_mutation_contracts.py new file mode 100644 index 00000000..276f1dab --- /dev/null +++ b/tests/unit/runtime/test_graph_mutation_contracts.py @@ -0,0 +1,45 @@ +from uuid import uuid4 + +from ergon_core.core.dashboard.event_contracts import DashboardGraphMutationEvent +from ergon_core.core.runtime.services.graph_dto import ( + EdgeAddedMutation, + GraphMutationRecordDto, + GraphMutationValue, +) +from pydantic import TypeAdapter + + +def test_rest_and_dashboard_mutations_share_graph_mutation_record_payloads() -> None: + run_id = uuid4() + mutation_id = uuid4() + edge_id = uuid4() + source_id = uuid4() + target_id = uuid4() + + payload = EdgeAddedMutation( + source_node_id=source_id, + target_node_id=target_id, + status="pending", + ) + + TypeAdapter(GraphMutationValue).validate_python(payload.model_dump(mode="json")) + + record = GraphMutationRecordDto( + id=mutation_id, + run_id=run_id, + sequence=1, + mutation_type="edge.added", + target_type="edge", + target_id=edge_id, + actor="test", + old_value=None, + new_value=payload, + reason=None, + created_at="2026-04-28T00:00:00Z", + ) + dashboard = DashboardGraphMutationEvent( + mutation=record, + ) + + assert dashboard.mutation == record + assert record.new_value == payload From 3ac0cfcb37ea3503f65431b764653febb0d5fae7 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:47:13 +0100 Subject: [PATCH 24/66] Collapse duplicate task node projections Made-with: Cursor --- ergon_core/ergon_core/core/api/runs.py | 2 +- ergon_core/ergon_core/core/api/schemas.py | 8 +++++++- .../core/dashboard/event_contracts.py | 10 +++++++--- .../core/runtime/inngest/start_workflow.py | 6 ++++++ .../core/runtime/services/graph_dto.py | 14 +++++++++++++ .../core/runtime/services/workflow_dto.py | 20 +++++-------------- .../core/runtime/services/workflow_service.py | 10 +++++----- .../architecture/test_core_schema_sources.py | 12 +++++++++++ 8 files changed, 57 insertions(+), 25 deletions(-) diff --git a/ergon_core/ergon_core/core/api/runs.py b/ergon_core/ergon_core/core/api/runs.py index 16712436..88a2581f 100644 --- a/ergon_core/ergon_core/core/api/runs.py +++ b/ergon_core/ergon_core/core/api/runs.py @@ -89,7 +89,7 @@ def _build_task_map( is_leaf=True, level=node.level, assigned_worker_id=str(worker.id) if worker else None, - assigned_worker_name=node.assigned_worker_slug, + assigned_worker_slug=node.assigned_worker_slug, started_at=started_at, completed_at=completed_at, ) diff --git a/ergon_core/ergon_core/core/api/schemas.py b/ergon_core/ergon_core/core/api/schemas.py index 481a562c..3e1edc2e 100644 --- a/ergon_core/ergon_core/core/api/schemas.py +++ b/ergon_core/ergon_core/core/api/schemas.py @@ -29,6 +29,12 @@ class CamelModel(BaseModel): class RunTaskDto(CamelModel): + """REST projection of RunGraphNode for run detail pages. + + This is not the canonical graph schema; graph semantics live in + runtime/services/graph_dto.py and persistence/graph/status_conventions.py. + """ + id: str name: str description: str @@ -39,7 +45,7 @@ class RunTaskDto(CamelModel): is_leaf: bool level: int assigned_worker_id: str | None = None - assigned_worker_name: str | None = None + assigned_worker_slug: str | None = None started_at: datetime | None = None completed_at: datetime | None = None diff --git a/ergon_core/ergon_core/core/dashboard/event_contracts.py b/ergon_core/ergon_core/core/dashboard/event_contracts.py index 4ed4bda7..cadcd658 100644 --- a/ergon_core/ergon_core/core/dashboard/event_contracts.py +++ b/ergon_core/ergon_core/core/dashboard/event_contracts.py @@ -20,6 +20,7 @@ ContextEventPayload, ContextEventType, ) +from ergon_core.core.persistence.graph.status_conventions import NodeStatus from ergon_core.core.runtime.events.base import InngestEventContract from ergon_core.core.runtime.services.cohort_schemas import CohortSummaryDto from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto @@ -54,6 +55,9 @@ class TaskTreeNode(BaseModel): id: str name: str description: str + status: NodeStatus + level: int + assigned_worker_slug: str | None = None assigned_to: WorkerRef children: list["TaskTreeNode"] = [] depends_on: list[str] = [] @@ -104,12 +108,12 @@ class DashboardTaskStatusChangedEvent(InngestEventContract): task_id: UUID task_name: str parent_task_id: UUID | None = None - old_status: str | None = None - new_status: str + old_status: NodeStatus | None = None + new_status: NodeStatus triggered_by: str | None = None timestamp: datetime assigned_worker_id: UUID | None = None - assigned_worker_name: str | None = None + assigned_worker_slug: str | None = None class DashboardTaskEvaluationUpdatedEvent(InngestEventContract): diff --git a/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py b/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py index da57ea8d..60016f82 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py +++ b/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py @@ -107,6 +107,9 @@ def build(node_id: UUID) -> TaskTreeNode: id=str(node.id), name=node.task_slug, description=node.description, + status=node.status, + level=node.level, + assigned_worker_slug=node.assigned_worker_slug, assigned_to=_worker_ref_for_slug(node.assigned_worker_slug, worker_rows_by_key), children=[build(c) for c in child_ids], depends_on=[str(s) for s in depends_on_by_target.get(node_id, [])], @@ -127,6 +130,9 @@ def build(node_id: UUID) -> TaskTreeNode: id=str(synthetic_id), name="workflow", description="Synthetic root node wrapping all definition roots.", + status="pending", + level=-1, + assigned_worker_slug=None, assigned_to=_worker_ref_for_slug(None, worker_rows_by_key), children=children, depends_on=[], diff --git a/ergon_core/ergon_core/core/runtime/services/graph_dto.py b/ergon_core/ergon_core/core/runtime/services/graph_dto.py index 92f5b961..86df4371 100644 --- a/ergon_core/ergon_core/core/runtime/services/graph_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/graph_dto.py @@ -12,6 +12,7 @@ from typing import Annotated, Literal from uuid import UUID +from ergon_core.core.persistence.graph.status_conventions import NodeStatus from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.graph.models import GraphTargetType, MutationType from ergon_core.core.persistence.shared.types import ( @@ -57,6 +58,19 @@ class GraphNodeDto(BaseModel): level: int +class GraphTaskRef(BaseModel): + """Lightweight task-node reference for workflow/tool projections.""" + + model_config = {"frozen": True} + + node_id: NodeId + task_slug: str + status: NodeStatus + level: int + parent_node_id: NodeId | None = None + assigned_worker_slug: str | None = None + + class GraphEdgeDto(BaseModel): model_config = {"frozen": True} diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_dto.py b/ergon_core/ergon_core/core/runtime/services/workflow_dto.py index d5b45aaa..77f2ce24 100644 --- a/ergon_core/ergon_core/core/runtime/services/workflow_dto.py +++ b/ergon_core/ergon_core/core/runtime/services/workflow_dto.py @@ -1,20 +1,10 @@ from datetime import datetime from uuid import UUID +from ergon_core.core.runtime.services.graph_dto import GraphTaskRef from pydantic import BaseModel, Field -class WorkflowTaskRef(BaseModel): - model_config = {"frozen": True} - - node_id: UUID - task_slug: str - status: str - level: int - parent_node_id: UUID | None = None - assigned_worker_slug: str | None = None - - class WorkflowExecutionRef(BaseModel): model_config = {"frozen": True} @@ -47,14 +37,14 @@ class WorkflowDependencyRef(BaseModel): edge_id: UUID edge_status: str - source: WorkflowTaskRef - target: WorkflowTaskRef + source: GraphTaskRef + target: GraphTaskRef class WorkflowBlockerRef(BaseModel): model_config = {"frozen": True} - task: WorkflowTaskRef + task: GraphTaskRef reason: str details: list[str] = Field(default_factory=list) suggested_commands: list[str] = Field(default_factory=list) @@ -64,7 +54,7 @@ class WorkflowNextActionRef(BaseModel): model_config = {"frozen": True} priority: str - task: WorkflowTaskRef | None = None + task: GraphTaskRef | None = None summary: str suggested_commands: list[str] = Field(default_factory=list) diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_service.py b/ergon_core/ergon_core/core/runtime/services/workflow_service.py index 11fa2a0c..31712268 100644 --- a/ergon_core/ergon_core/core/runtime/services/workflow_service.py +++ b/ergon_core/ergon_core/core/runtime/services/workflow_service.py @@ -21,13 +21,13 @@ AddSubtaskResult, ) from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from ergon_core.core.runtime.services.graph_dto import GraphTaskRef from ergon_core.core.runtime.services.workflow_dto import ( WorkflowBlockerRef, WorkflowDependencyRef, WorkflowMaterializedResourceRef, WorkflowNextActionRef, WorkflowResourceRef, - WorkflowTaskRef, ) from sqlmodel import Session, col, select @@ -56,7 +56,7 @@ def list_tasks( *, run_id: UUID, parent_node_id: UUID | None = None, - ) -> list[WorkflowTaskRef]: + ) -> list[GraphTaskRef]: stmt = select(RunGraphNode).where(RunGraphNode.run_id == run_id) if parent_node_id is not None: stmt = stmt.where(RunGraphNode.parent_node_id == parent_node_id) @@ -71,7 +71,7 @@ def get_task( run_id: UUID, node_id: UUID | None = None, task_slug: str | None = None, - ) -> WorkflowTaskRef: + ) -> GraphTaskRef: node = self._resolve_node(session, run_id=run_id, node_id=node_id, task_slug=task_slug) return self._task_ref(node) @@ -314,8 +314,8 @@ def _sandbox_manager_for(benchmark_type: str) -> BaseSandboxManager: return DefaultSandboxManager() @staticmethod - def _task_ref(node: RunGraphNode) -> WorkflowTaskRef: - return WorkflowTaskRef( + def _task_ref(node: RunGraphNode) -> GraphTaskRef: + return GraphTaskRef( node_id=node.id, task_slug=node.task_slug, status=node.status, diff --git a/tests/unit/architecture/test_core_schema_sources.py b/tests/unit/architecture/test_core_schema_sources.py index 58f1e8b3..866abe9f 100644 --- a/tests/unit/architecture/test_core_schema_sources.py +++ b/tests/unit/architecture/test_core_schema_sources.py @@ -41,3 +41,15 @@ def test_eval_criterion_status_literal_is_defined_only_in_evaluation_summary() - offenders.append(str(path.relative_to(ROOT))) assert offenders == [] + + +def test_run_task_dto_does_not_label_worker_slug_as_name() -> None: + path = ROOT / "ergon_core/ergon_core/core/api/schemas.py" + text = path.read_text() + assert "assigned_worker_name" not in text + assert "assigned_worker_slug" in text + + +def test_workflow_task_ref_does_not_duplicate_graph_task_ref() -> None: + path = ROOT / "ergon_core/ergon_core/core/runtime/services/workflow_dto.py" + assert "class WorkflowTaskRef" not in path.read_text() From b114390604b198920a68ad8db625366bd88b6b4d Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:49:06 +0100 Subject: [PATCH 25/66] test: add missing tool budget module Restore the module required by workflow CLI tool tests so focused verification collects cleanly. Made-with: Cursor --- .../workers/baselines/tool_budget.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py diff --git a/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py b/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py new file mode 100644 index 00000000..2b590ac5 --- /dev/null +++ b/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +ToolBudgetKind = Literal["workflow", "other", "finalization"] +ToolBudgetExhaustedStatus = Literal["TOOL_BUDGET_EXHAUSTED"] + + +class AgentToolBudgetExhaustedResult(BaseModel): + status: ToolBudgetExhaustedStatus = "TOOL_BUDGET_EXHAUSTED" + reason: str + message: str + budget_state: dict[str, Any] # slopcop: ignore[no-typing-any] + + +class AgentToolBudgetState(BaseModel): + max_workflow_tool_calls: int = 12 + max_other_tool_calls: int = 12 + workflow_tool_calls: int = 0 + other_tool_calls: int = 0 + finalization_tool_calls: int = 0 + calls_by_tool: dict[str, int] = Field(default_factory=dict) + + def increment(self, tool_name: str, kind: ToolBudgetKind) -> int: + self.calls_by_tool[tool_name] = self.calls_by_tool.get(tool_name, 0) + 1 + + if kind == "workflow": + self.workflow_tool_calls += 1 + return self.workflow_tool_calls + if kind == "finalization": + self.finalization_tool_calls += 1 + return self.finalization_tool_calls + self.other_tool_calls += 1 + return self.other_tool_calls + + def snapshot(self) -> dict[str, Any]: # slopcop: ignore[no-typing-any] + return { + "workflow_tool_calls": self.workflow_tool_calls, + "max_workflow_tool_calls": self.max_workflow_tool_calls, + "other_tool_calls": self.other_tool_calls, + "max_other_tool_calls": self.max_other_tool_calls, + "finalization_tool_calls": self.finalization_tool_calls, + "calls_by_tool": dict(sorted(self.calls_by_tool.items())), + } + + def exhausted_result(self, reason: str) -> AgentToolBudgetExhaustedResult: + return AgentToolBudgetExhaustedResult( + reason=reason, + message=( + "Stop calling tools in this category. Use the context/resources already " + "available and produce the best possible final output. If the output is " + "incomplete, state what context or resource was missing." + ), + budget_state=self.snapshot(), + ) + + +class AgentToolBudgetDeps(BaseModel): + tool_budget: AgentToolBudgetState From 3debc682036cb14fa34c9799de7d30678bfa27b3 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:51:24 +0100 Subject: [PATCH 26/66] fix: emit assigned worker slug in task status events Keep dashboard task-status emissions aligned with the canonical worker slug field introduced by the task-node DTO cleanup. Made-with: Cursor --- .../ergon_core/core/dashboard/emitter.py | 6 ++-- .../services/task_execution_service.py | 8 ++--- .../dashboard/test_event_contract_types.py | 29 +++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/ergon_core/ergon_core/core/dashboard/emitter.py b/ergon_core/ergon_core/core/dashboard/emitter.py index e9ca438e..004a0e6d 100644 --- a/ergon_core/ergon_core/core/dashboard/emitter.py +++ b/ergon_core/ergon_core/core/dashboard/emitter.py @@ -139,7 +139,7 @@ async def task_status_changed( # slopcop: ignore[max-function-params] parent_task_id: UUID | None = None, triggered_by: str | None = None, assigned_worker_id: UUID | None = None, - assigned_worker_name: str | None = None, + assigned_worker_slug: str | None = None, ) -> None: if not self._enabled: return @@ -154,7 +154,7 @@ async def task_status_changed( # slopcop: ignore[max-function-params] triggered_by=triggered_by, timestamp=utcnow(), assigned_worker_id=assigned_worker_id, - assigned_worker_name=assigned_worker_name, + assigned_worker_slug=assigned_worker_slug, ) await inngest_client.send( inngest.Event(name=evt.name, data=evt.model_dump(mode="json")) @@ -202,7 +202,7 @@ async def task_cancelled(self, event: TaskCancelledEvent) -> None: triggered_by=f"cancel:{event.cause}", timestamp=utcnow(), assigned_worker_id=None, - assigned_worker_name=None, + assigned_worker_slug=None, ) await inngest_client.send( inngest.Event(name=evt.name, data=evt.model_dump(mode="json")) diff --git a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py b/ergon_core/ergon_core/core/runtime/services/task_execution_service.py index 075f3c33..01e750e9 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_execution_service.py @@ -44,7 +44,7 @@ async def _emit_task_status( new_status: str, old_status: str | None = None, worker_id: UUID | None = None, - worker_name: str | None = None, + worker_slug: str | None = None, ) -> None: """Emit dashboard/task.status_changed. All arguments are plain primitives.""" if node_id is None: @@ -57,7 +57,7 @@ async def _emit_task_status( new_status=new_status, old_status=old_status, assigned_worker_id=worker_id, - assigned_worker_name=worker_name, + assigned_worker_slug=worker_slug, ) except Exception: # slopcop: ignore[no-broad-except] logger.warning("Failed to emit task_status_changed", exc_info=True) @@ -156,7 +156,7 @@ async def _prepare_graph_native( new_status=graph_status.RUNNING, old_status=None, worker_id=worker_row.id, - worker_name=assigned_worker_slug, + worker_slug=assigned_worker_slug, ) # Graph-native path: ``command.node_id`` is guaranteed non-null @@ -276,7 +276,7 @@ async def _prepare_definition( new_status=graph_status.RUNNING, old_status=None, worker_id=definition_worker_id, - worker_name=assigned_worker_slug, + worker_slug=assigned_worker_slug, ) # Definition path: ``command.task_id`` is the static FK (known diff --git a/tests/unit/dashboard/test_event_contract_types.py b/tests/unit/dashboard/test_event_contract_types.py index e984decc..3737d3ce 100644 --- a/tests/unit/dashboard/test_event_contract_types.py +++ b/tests/unit/dashboard/test_event_contract_types.py @@ -1,5 +1,10 @@ """Guards for typed dashboard event payload contracts.""" +from uuid import uuid4 + +import pytest +from ergon_core.core.dashboard import emitter as dashboard_emitter_module +from ergon_core.core.dashboard.emitter import DashboardEmitter from ergon_core.core.api.schemas import ( RunCommunicationMessageDto, RunCommunicationThreadDto, @@ -32,3 +37,27 @@ def test_thread_dto_exposes_summary_and_task_identity() -> None: def test_cohort_updated_event_uses_cohort_summary_dto() -> None: assert CohortUpdatedEvent.model_fields["summary"].annotation is CohortSummaryDto + + +@pytest.mark.asyncio +async def test_task_status_emitter_uses_assigned_worker_slug(monkeypatch: pytest.MonkeyPatch) -> None: + sent_events = [] + + async def send(event) -> None: + sent_events.append(event) + + monkeypatch.setattr(dashboard_emitter_module.inngest_client, "send", send) + + emitter = DashboardEmitter(enabled=True) + await emitter.task_status_changed( + run_id=uuid4(), + task_id=uuid4(), + task_name="task", + new_status="running", + assigned_worker_slug="react-worker", + ) + + assert len(sent_events) == 1 + data = sent_events[0].data + assert data["assigned_worker_slug"] == "react-worker" + assert "assigned_worker_name" not in data From de7c73b1779cce27a52e6789ca6071e8f4ae3c58 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:53:06 +0100 Subject: [PATCH 27/66] Centralize task cancellation causes Made-with: Cursor --- .../core/runtime/events/task_events.py | 1 + .../services/subtask_cancellation_service.py | 5 ++--- .../architecture/test_core_schema_sources.py | 22 +++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ergon_core/ergon_core/core/runtime/events/task_events.py b/ergon_core/ergon_core/core/runtime/events/task_events.py index 9fd8f217..42719db5 100644 --- a/ergon_core/ergon_core/core/runtime/events/task_events.py +++ b/ergon_core/ergon_core/core/runtime/events/task_events.py @@ -103,6 +103,7 @@ class WorkflowFailedEvent(InngestEventContract): "downstream_invalidation", "run_cancelled", ] +PropagationCancelCause = Literal["parent_terminal", "dep_invalidated"] class TaskCancelledEvent(InngestEventContract): diff --git a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py b/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py index aae83597..e7e130b3 100644 --- a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py +++ b/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py @@ -9,7 +9,6 @@ import logging from collections import deque -from typing import Literal from uuid import UUID from ergon_core.core.persistence.graph.models import RunGraphNode @@ -18,7 +17,7 @@ TERMINAL_STATUSES, ) from ergon_core.core.persistence.telemetry.models import RunTaskExecution -from ergon_core.core.runtime.events.task_events import TaskCancelledEvent +from ergon_core.core.runtime.events.task_events import PropagationCancelCause, TaskCancelledEvent from ergon_core.core.runtime.services.graph_dto import MutationMeta from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository from ergon_core.core.runtime.services.subtask_cancellation_dto import CancelOrphansResult @@ -54,7 +53,7 @@ async def cancel_orphans( run_id: UUID, definition_id: UUID, parent_node_id: UUID, - cause: Literal["parent_terminal", "dep_invalidated"], + cause: PropagationCancelCause, ) -> CancelOrphansResult: """Recursively cancel every non-terminal descendant of parent_node_id. diff --git a/tests/unit/architecture/test_core_schema_sources.py b/tests/unit/architecture/test_core_schema_sources.py index 866abe9f..92437764 100644 --- a/tests/unit/architecture/test_core_schema_sources.py +++ b/tests/unit/architecture/test_core_schema_sources.py @@ -53,3 +53,25 @@ def test_run_task_dto_does_not_label_worker_slug_as_name() -> None: def test_workflow_task_ref_does_not_duplicate_graph_task_ref() -> None: path = ROOT / "ergon_core/ergon_core/core/runtime/services/workflow_dto.py" assert "class WorkflowTaskRef" not in path.read_text() + + +def test_cancel_cause_literals_live_in_task_events() -> None: + offenders: list[str] = [] + snippets = ( + 'Literal["parent_terminal", "dep_invalidated"]', + 'Literal["dep_invalidated", "parent_terminal"]', + ) + allowed = { + ROOT / "ergon_core/ergon_core/core/runtime/events/task_events.py", + } + + for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): + if path in allowed: + continue + text = path.read_text() + compact_text = "".join(text.split()).replace(",]", "]") + for snippet in snippets: + if snippet in text or "".join(snippet.split()) in compact_text: + offenders.append(f"{path.relative_to(ROOT)} duplicates cancel cause subset") + + assert offenders == [] From 780308798b76755fcee63cd232067558a2175178 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:55:52 +0100 Subject: [PATCH 28/66] Share typed context event payload schemas Made-with: Cursor --- ergon_core/ergon_core/core/api/runs.py | 15 +++++----- ergon_core/ergon_core/core/api/schemas.py | 22 +++++++++------ .../ergon_core/core/dashboard/emitter.py | 5 +--- .../runtime/test_context_event_contracts.py | 28 +++++++++++++++++++ 4 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 tests/unit/runtime/test_context_event_contracts.py diff --git a/ergon_core/ergon_core/core/api/runs.py b/ergon_core/ergon_core/core/api/runs.py index 88a2581f..3745207b 100644 --- a/ergon_core/ergon_core/core/api/runs.py +++ b/ergon_core/ergon_core/core/api/runs.py @@ -381,16 +381,17 @@ def _context_events_by_task( continue context_events_by_task[str(task_node_id)].append( RunContextEventDto( - id=str(event.id), - task_execution_id=str(event.task_execution_id), - task_node_id=str(task_node_id), + id=event.id, + run_id=event.run_id, + task_execution_id=event.task_execution_id, + task_node_id=task_node_id, worker_binding_key=event.worker_binding_key, sequence=event.sequence, event_type=event.event_type, - payload=event.payload, - created_at=event.created_at.isoformat(), - started_at=event.started_at.isoformat() if event.started_at else None, - completed_at=event.completed_at.isoformat() if event.completed_at else None, + payload=event.parsed_payload(), + created_at=event.created_at, + started_at=event.started_at, + completed_at=event.completed_at, ) ) return dict(context_events_by_task) diff --git a/ergon_core/ergon_core/core/api/schemas.py b/ergon_core/ergon_core/core/api/schemas.py index 3e1edc2e..8ae9fd87 100644 --- a/ergon_core/ergon_core/core/api/schemas.py +++ b/ergon_core/ergon_core/core/api/schemas.py @@ -7,7 +7,12 @@ from datetime import datetime from typing import Any +from uuid import UUID +from ergon_core.core.persistence.context.event_payloads import ( + ContextEventPayload, + ContextEventType, +) from ergon_core.core.persistence.telemetry.evaluation_summary import EvalCriterionStatus from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto from pydantic import BaseModel, ConfigDict, Field @@ -167,16 +172,17 @@ class RunCommunicationThreadDto(CamelModel): class RunContextEventDto(CamelModel): - id: str - task_execution_id: str - task_node_id: str + id: UUID + run_id: UUID + task_execution_id: UUID + task_node_id: UUID worker_binding_key: str sequence: int - event_type: str - payload: dict[str, Any] # slopcop: ignore[no-typing-any] - created_at: str - started_at: str | None = None - completed_at: str | None = None + event_type: ContextEventType + payload: ContextEventPayload + created_at: datetime + started_at: datetime | None = None + completed_at: datetime | None = None class RunSnapshotDto(CamelModel): diff --git a/ergon_core/ergon_core/core/dashboard/emitter.py b/ergon_core/ergon_core/core/dashboard/emitter.py index 004a0e6d..888a26dd 100644 --- a/ergon_core/ergon_core/core/dashboard/emitter.py +++ b/ergon_core/ergon_core/core/dashboard/emitter.py @@ -15,9 +15,6 @@ RunTaskEvaluationDto, ) from ergon_core.core.persistence.context.event_payloads import ContextEventType -from ergon_core.core.persistence.context.models import ( - _PAYLOAD_ADAPTER, -) from ergon_core.core.persistence.graph.models import ( RunGraphMutation, ) @@ -412,7 +409,7 @@ async def on_context_event(self, event: "RunContextEvent") -> None: worker_binding_key=event.worker_binding_key, sequence=event.sequence, event_type=cast(ContextEventType, event.event_type), - payload=_PAYLOAD_ADAPTER.validate_python(event.payload), + payload=event.parsed_payload(), created_at=event.created_at, started_at=event.started_at, completed_at=event.completed_at, diff --git a/tests/unit/runtime/test_context_event_contracts.py b/tests/unit/runtime/test_context_event_contracts.py new file mode 100644 index 00000000..884997ca --- /dev/null +++ b/tests/unit/runtime/test_context_event_contracts.py @@ -0,0 +1,28 @@ +from uuid import uuid4 + +from ergon_core.core.api.schemas import RunContextEventDto +from ergon_core.core.dashboard.event_contracts import DashboardContextEventEvent +from ergon_core.core.persistence.context.event_payloads import AssistantTextPayload + + +def test_rest_and_dashboard_context_events_share_typed_payload_shape() -> None: + payload = AssistantTextPayload(text="hello", turn_id="turn-1") + common = { + "id": uuid4(), + "run_id": uuid4(), + "task_execution_id": uuid4(), + "task_node_id": uuid4(), + "worker_binding_key": "worker", + "sequence": 1, + "event_type": "assistant_text", + "payload": payload, + "created_at": "2026-04-28T00:00:00Z", + "started_at": None, + "completed_at": None, + } + + rest = RunContextEventDto.model_validate(common) + dashboard = DashboardContextEventEvent.model_validate(common) + + assert rest.payload == dashboard.payload + assert rest.event_type == dashboard.event_type From 1f134ab6c25fea1cda377025ba8f91af0ca0aaa9 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:57:33 +0100 Subject: [PATCH 29/66] Guard generation to context event mapping Made-with: Cursor --- .../builtins/common/test_transcript_adapters.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/builtins/common/test_transcript_adapters.py b/tests/unit/builtins/common/test_transcript_adapters.py index 6d98b8a2..f7092cd8 100644 --- a/tests/unit/builtins/common/test_transcript_adapters.py +++ b/tests/unit/builtins/common/test_transcript_adapters.py @@ -15,6 +15,7 @@ ) from ergon_core.core.persistence.context.event_payloads import ( AssistantTextPayload, + ContextEventType, SystemPromptPayload, ThinkingPayload, ToolCallPayload, @@ -57,6 +58,21 @@ def _make_event(event_type: str, payload, sequence: int) -> RunContextEvent: ) +def test_generation_part_kinds_have_context_event_counterparts() -> None: + assert ErgonTextPart(content="x").part_kind == "text" + assert ErgonThinkingPart(content="x").part_kind == "thinking" + assert ErgonToolCallPart(tool_name="t", tool_call_id="1", args={}).part_kind == "tool-call" + assert ( + ErgonToolReturnPart(tool_call_id="1", tool_name="t", content="ok").part_kind + == "tool-return" + ) + + assert "assistant_text" in ContextEventType.__args__ + assert "thinking" in ContextEventType.__args__ + assert "tool_call" in ContextEventType.__args__ + assert "tool_result" in ContextEventType.__args__ + + def test_text_and_thinking_are_response_parts() -> None: adapter: TranscriptAdapter[ list[ModelRequest | ModelResponse], list[ModelRequest | ModelResponse] From 060b9278903fa7dfe3e3c00bc3077c2373ddb608 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:58:52 +0100 Subject: [PATCH 30/66] Guard core schema source ownership Add the final architecture check that keeps API and dashboard schemas importing canonical domain payloads instead of redefining them. Made-with: Cursor --- .../architecture/test_core_schema_sources.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/architecture/test_core_schema_sources.py b/tests/unit/architecture/test_core_schema_sources.py index 92437764..ca5b349c 100644 --- a/tests/unit/architecture/test_core_schema_sources.py +++ b/tests/unit/architecture/test_core_schema_sources.py @@ -75,3 +75,26 @@ def test_cancel_cause_literals_live_in_task_events() -> None: offenders.append(f"{path.relative_to(ROOT)} duplicates cancel cause subset") assert offenders == [] + + +def test_core_schema_source_imports_are_directional() -> None: + forbidden_pairs = { + "ergon_core.core.api.schemas": ( + "EvalCriterionStatus = Literal", + "GraphMutationValue =", + ), + "ergon_core.core.dashboard.event_contracts": ( + "GraphMutationValue =", + "CancelCause = Literal", + ), + } + + offenders: list[str] = [] + for module_path, snippets in forbidden_pairs.items(): + path = ROOT / ("ergon_core/" + module_path.replace(".", "/") + ".py") + text = path.read_text() + for snippet in snippets: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} contains local source {snippet!r}") + + assert offenders == [] From 88fbc6fdfc9f8a118f639bb8ad3a419bf3e7c27c Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:15:47 +0100 Subject: [PATCH 31/66] Align dashboard contracts with core schemas Made-with: Cursor --- .../scripts/generate-rest-contracts.mjs | 7 +- .../activity/buildRunActivities.test.ts | 1 + .../src/features/evaluation/selectors.test.ts | 3 +- .../events/DashboardTaskStatusChangedEvent.ts | 2 +- ...oardTaskEvaluationUpdatedEvent.schema.json | 18 + ...ashboardTaskStatusChangedEvent.schema.json | 22 +- .../DashboardWorkflowStartedEvent.schema.json | 31 + .../src/generated/rest/contracts.ts | 200 +++-- .../src/generated/rest/openapi.json | 728 +++++++++++++----- .../src/inngest/functions/onContextEvent.ts | 1 + .../src/lib/contracts/contextEvents.ts | 11 + ergon-dashboard/src/lib/contracts/events.ts | 5 +- ergon-dashboard/src/lib/contracts/rest.ts | 6 +- ergon-dashboard/src/lib/runState.ts | 1 + .../tests/contracts/contracts.test.ts | 23 +- .../fixtures/mas-runs/concurrent-mas-run.json | 20 +- .../tests/helpers/dashboardFixtures.ts | 29 +- 17 files changed, 851 insertions(+), 257 deletions(-) diff --git a/ergon-dashboard/scripts/generate-rest-contracts.mjs b/ergon-dashboard/scripts/generate-rest-contracts.mjs index 558239b9..a93ae8d3 100644 --- a/ergon-dashboard/scripts/generate-rest-contracts.mjs +++ b/ergon-dashboard/scripts/generate-rest-contracts.mjs @@ -9,7 +9,12 @@ const contractsPath = path.resolve(__dirname, "../src/generated/rest/contracts.t const source = readFileSync(contractsPath, "utf8") .replace('import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core";\n', "") // openapi-zod-client generates z.record(V) but Zod requires z.record(K, V). - .replace(/z\.record\((?!z\.string\(\))/g, "z.record(z.string(), "); + .replace(/z\.record\((?!z\.string\(\))/g, "z.record(z.string(), ") + // Preserve literal discriminators for generated context-event payload unions. + .replace( + /event_type: z\.string\(\)\.optional\(\)\.default\("([^"]+)"\)/g, + 'event_type: z.literal("$1").default("$1")', + ); const endpointMarker = "\nconst endpoints = makeApi(["; const markerIndex = source.indexOf(endpointMarker); diff --git a/ergon-dashboard/src/features/activity/buildRunActivities.test.ts b/ergon-dashboard/src/features/activity/buildRunActivities.test.ts index 9183939a..456b42ee 100644 --- a/ergon-dashboard/src/features/activity/buildRunActivities.test.ts +++ b/ergon-dashboard/src/features/activity/buildRunActivities.test.ts @@ -54,6 +54,7 @@ test("buildRunActivities surfaces semantic activity kinds without creating actor runState.contextEventsByTask.set(noisyTaskId, [ { id: "context-noisy", + runId: runState.id, taskExecutionId: "execution-noisy", taskNodeId: noisyTaskId, workerBindingKey: "worker-1", diff --git a/ergon-dashboard/src/features/evaluation/selectors.test.ts b/ergon-dashboard/src/features/evaluation/selectors.test.ts index c192c589..f75e65c0 100644 --- a/ergon-dashboard/src/features/evaluation/selectors.test.ts +++ b/ergon-dashboard/src/features/evaluation/selectors.test.ts @@ -22,7 +22,7 @@ function task(id: string, childIds: string[] = []): TaskState { isLeaf: childIds.length === 0, level: 0, assignedWorkerId: null, - assignedWorkerName: null, + assignedWorkerSlug: null, startedAt: null, completedAt: null, history: [], @@ -49,6 +49,7 @@ function evaluation(taskId: string, statuses: Array<"passed" | "failed" | "error stageNum: 0, stageName: "default", criterionNum: index, + criterionSlug: `${status}_criterion`, criterionType: "fixture", criterionDescription: `${status} criterion`, criterionName: `${status} criterion`, diff --git a/ergon-dashboard/src/generated/events/DashboardTaskStatusChangedEvent.ts b/ergon-dashboard/src/generated/events/DashboardTaskStatusChangedEvent.ts index 4314689e..2fa0feb0 100644 --- a/ergon-dashboard/src/generated/events/DashboardTaskStatusChangedEvent.ts +++ b/ergon-dashboard/src/generated/events/DashboardTaskStatusChangedEvent.ts @@ -1,3 +1,3 @@ import { z } from "zod" -export const DashboardTaskStatusChangedEventSchema = z.object({ "run_id": z.string().uuid(), "task_id": z.string().uuid(), "task_name": z.string(), "parent_task_id": z.union([z.string().uuid(), z.null()]).default(null), "old_status": z.union([z.string(), z.null()]).default(null), "new_status": z.string(), "triggered_by": z.union([z.string(), z.null()]).default(null), "timestamp": z.string().datetime({ offset: true }), "assigned_worker_id": z.union([z.string().uuid(), z.null()]).default(null), "assigned_worker_name": z.union([z.string(), z.null()]).default(null) }).catchall(z.any()) +export const DashboardTaskStatusChangedEventSchema = z.object({ "run_id": z.string().uuid(), "task_id": z.string().uuid(), "task_name": z.string(), "parent_task_id": z.union([z.string().uuid(), z.null()]).default(null), "old_status": z.union([z.enum(["pending","ready","running","completed","failed","cancelled","blocked"]), z.null()]).default(null), "new_status": z.enum(["pending","ready","running","completed","failed","cancelled","blocked"]), "triggered_by": z.union([z.string(), z.null()]).default(null), "timestamp": z.string().datetime({ offset: true }), "assigned_worker_id": z.union([z.string().uuid(), z.null()]).default(null), "assigned_worker_slug": z.union([z.string(), z.null()]).default(null) }).catchall(z.any()) diff --git a/ergon-dashboard/src/generated/events/schemas/DashboardTaskEvaluationUpdatedEvent.schema.json b/ergon-dashboard/src/generated/events/schemas/DashboardTaskEvaluationUpdatedEvent.schema.json index 2539d79c..d78ada70 100644 --- a/ergon-dashboard/src/generated/events/schemas/DashboardTaskEvaluationUpdatedEvent.schema.json +++ b/ergon-dashboard/src/generated/events/schemas/DashboardTaskEvaluationUpdatedEvent.schema.json @@ -19,6 +19,10 @@ "title": "Criterionnum", "type": "integer" }, + "criterionSlug": { + "title": "Criterionslug", + "type": "string" + }, "criterionType": { "title": "Criteriontype", "type": "string" @@ -123,6 +127,19 @@ "title": "Evaluatedresourceids", "type": "array" }, + "observation": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Observation" + }, "error": { "anyOf": [ { @@ -142,6 +159,7 @@ "stageNum", "stageName", "criterionNum", + "criterionSlug", "criterionType", "criterionDescription", "criterionName", diff --git a/ergon-dashboard/src/generated/events/schemas/DashboardTaskStatusChangedEvent.schema.json b/ergon-dashboard/src/generated/events/schemas/DashboardTaskStatusChangedEvent.schema.json index f4cad365..1e22360e 100644 --- a/ergon-dashboard/src/generated/events/schemas/DashboardTaskStatusChangedEvent.schema.json +++ b/ergon-dashboard/src/generated/events/schemas/DashboardTaskStatusChangedEvent.schema.json @@ -31,6 +31,15 @@ "old_status": { "anyOf": [ { + "enum": [ + "pending", + "ready", + "running", + "completed", + "failed", + "cancelled", + "blocked" + ], "type": "string" }, { @@ -41,6 +50,15 @@ "title": "Old Status" }, "new_status": { + "enum": [ + "pending", + "ready", + "running", + "completed", + "failed", + "cancelled", + "blocked" + ], "title": "New Status", "type": "string" }, @@ -74,7 +92,7 @@ "default": null, "title": "Assigned Worker Id" }, - "assigned_worker_name": { + "assigned_worker_slug": { "anyOf": [ { "type": "string" @@ -84,7 +102,7 @@ } ], "default": null, - "title": "Assigned Worker Name" + "title": "Assigned Worker Slug" } }, "required": [ diff --git a/ergon-dashboard/src/generated/events/schemas/DashboardWorkflowStartedEvent.schema.json b/ergon-dashboard/src/generated/events/schemas/DashboardWorkflowStartedEvent.schema.json index 32c092db..4e4455fd 100644 --- a/ergon-dashboard/src/generated/events/schemas/DashboardWorkflowStartedEvent.schema.json +++ b/ergon-dashboard/src/generated/events/schemas/DashboardWorkflowStartedEvent.schema.json @@ -15,6 +15,35 @@ "title": "Description", "type": "string" }, + "status": { + "enum": [ + "pending", + "ready", + "running", + "completed", + "failed", + "cancelled", + "blocked" + ], + "title": "Status", + "type": "string" + }, + "level": { + "title": "Level", + "type": "integer" + }, + "assigned_worker_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Assigned Worker Slug" + }, "assigned_to": { "$ref": "#/$defs/WorkerRef" }, @@ -63,6 +92,8 @@ "id", "name", "description", + "status", + "level", "assigned_to", "is_leaf" ], diff --git a/ergon-dashboard/src/generated/rest/contracts.ts b/ergon-dashboard/src/generated/rest/contracts.ts index 60158d44..43905ca6 100644 --- a/ergon-dashboard/src/generated/rest/contracts.ts +++ b/ergon-dashboard/src/generated/rest/contracts.ts @@ -19,7 +19,7 @@ const RunTaskDto = z.object({ isLeaf: z.boolean(), level: z.number().int(), assignedWorkerId: z.union([z.string(), z.null()]).optional(), - assignedWorkerName: z.union([z.string(), z.null()]).optional(), + assignedWorkerSlug: z.union([z.string(), z.null()]).optional(), startedAt: z.union([z.string(), z.null()]).optional(), completedAt: z.union([z.string(), z.null()]).optional(), }); @@ -55,6 +55,7 @@ const RunEvaluationCriterionDto = z.object({ stageNum: z.number().int(), stageName: z.string(), criterionNum: z.number().int(), + criterionSlug: z.string(), criterionType: z.string(), criterionDescription: z.string(), criterionName: z.string(), @@ -70,6 +71,9 @@ const RunEvaluationCriterionDto = z.object({ skippedReason: z.union([z.string(), z.null()]).optional(), evaluatedActionIds: z.array(z.string()).optional(), evaluatedResourceIds: z.array(z.string()).optional(), + observation: z + .union([z.object({}).partial().passthrough(), z.null()]) + .optional(), error: z.union([z.object({}).partial().passthrough(), z.null()]).optional(), }); const RunTaskEvaluationDto = z.object({ @@ -106,15 +110,99 @@ const RunSandboxDto = z.object({ closeReason: z.union([z.string(), z.null()]).optional(), commands: z.array(RunSandboxCommandDto).optional(), }); +const SystemPromptPayload = z + .object({ + event_type: z.literal("system_prompt").default("system_prompt"), + text: z.string(), + }) + .passthrough(); +const UserMessagePayload = z + .object({ + event_type: z.literal("user_message").default("user_message"), + text: z.string(), + from_worker_key: z.union([z.string(), z.null()]).optional(), + }) + .passthrough(); +const JsonScalar = z.union([ + z.string(), + z.number(), + z.number(), + z.boolean(), + z.null(), +]); +const JsonValue: z.ZodType = z.lazy(() => + z.union([JsonScalar, z.array(JsonValue), z.record(z.string(), JsonValue)]) +); +const JsonObject = z.record(z.string(), JsonValue); +const TokenLogprob = z + .object({ + token: z.string(), + logprob: z.number(), + top_logprobs: z.array(JsonObject).optional(), + }) + .passthrough(); +const AssistantTextPayload = z + .object({ + event_type: z.literal("assistant_text").default("assistant_text"), + text: z.string(), + turn_id: z.string(), + turn_token_ids: z.union([z.array(z.number().int()), z.null()]).optional(), + turn_logprobs: z.union([z.array(TokenLogprob), z.null()]).optional(), + }) + .passthrough(); +const ToolCallPayload = z + .object({ + event_type: z.literal("tool_call").default("tool_call"), + tool_call_id: z.string(), + tool_name: z.string(), + args: z.object({}).partial().passthrough(), + turn_id: z.string(), + turn_token_ids: z.union([z.array(z.number().int()), z.null()]).optional(), + turn_logprobs: z.union([z.array(TokenLogprob), z.null()]).optional(), + }) + .passthrough(); +const ToolResultPayload = z + .object({ + event_type: z.literal("tool_result").default("tool_result"), + tool_call_id: z.string(), + tool_name: z.string(), + result: z.unknown(), + is_error: z.boolean().optional().default(false), + }) + .passthrough(); +const ThinkingPayload = z + .object({ + event_type: z.literal("thinking").default("thinking"), + text: z.string(), + turn_id: z.string(), + turn_token_ids: z.union([z.array(z.number().int()), z.null()]).optional(), + turn_logprobs: z.union([z.array(TokenLogprob), z.null()]).optional(), + }) + .passthrough(); const RunContextEventDto = z.object({ - id: z.string(), - taskExecutionId: z.string(), - taskNodeId: z.string(), + id: z.string().uuid(), + runId: z.string().uuid(), + taskExecutionId: z.string().uuid(), + taskNodeId: z.string().uuid(), workerBindingKey: z.string(), sequence: z.number().int(), - eventType: z.string(), - payload: z.object({}).partial().passthrough(), - createdAt: z.string(), + eventType: z.enum([ + "system_prompt", + "user_message", + "assistant_text", + "tool_call", + "tool_result", + "thinking", + ]), + payload: z.discriminatedUnion("event_type", [ + SystemPromptPayload, + UserMessagePayload, + AssistantTextPayload, + ToolCallPayload, + ToolResultPayload, + ThinkingPayload, + ]), + createdAt: z.string().datetime({ offset: true }), startedAt: z.union([z.string(), z.null()]).optional(), completedAt: z.union([z.string(), z.null()]).optional(), }); @@ -217,16 +305,16 @@ const NodeFieldChangedMutation = z const EdgeAddedMutation = z .object({ mutation_type: z.string().optional().default("edge.added"), - source_node_id: z.string(), - target_node_id: z.string(), + source_node_id: z.string().uuid(), + target_node_id: z.string().uuid(), status: z.string(), }) .passthrough(); const EdgeRemovedMutation = z .object({ mutation_type: z.string().optional().default("edge.removed"), - source_node_id: z.string(), - target_node_id: z.string(), + source_node_id: z.string().uuid(), + target_node_id: z.string().uuid(), status: z.string(), }) .passthrough(); @@ -236,17 +324,6 @@ const EdgeStatusChangedMutation = z status: z.string(), }) .passthrough(); -const JsonScalar = z.union([ - z.string(), - z.number(), - z.number(), - z.boolean(), - z.null(), -]); -const JsonValue: z.ZodType = z.lazy(() => - z.union([JsonScalar, z.array(JsonValue), z.record(z.string(), JsonValue)]) -); -const JsonObject = z.record(z.string(), JsonValue); const AnnotationSetMutation = z .object({ mutation_type: z.string().optional().default("annotation.set"), @@ -261,16 +338,40 @@ const AnnotationDeletedMutation = z payload: JsonObject, }) .passthrough(); -const RunGraphMutationDto = z.object({ - id: z.string(), - run_id: z.string(), - sequence: z.number().int(), - mutation_type: z.string(), - target_type: z.string(), - target_id: z.string(), - actor: z.string(), - old_value: z.union([ - z.discriminatedUnion("mutation_type", [ +const GraphMutationRecordDto = z + .object({ + id: z.string().uuid(), + run_id: z.string().uuid(), + sequence: z.number().int(), + mutation_type: z.enum([ + "node.added", + "node.removed", + "node.status_changed", + "node.field_changed", + "edge.added", + "edge.removed", + "edge.status_changed", + "annotation.set", + "annotation.deleted", + ]), + target_type: z.enum(["node", "edge"]), + target_id: z.string().uuid(), + actor: z.string(), + old_value: z.union([ + z.discriminatedUnion("mutation_type", [ + NodeAddedMutation, + NodeRemovedMutation, + NodeStatusChangedMutation, + NodeFieldChangedMutation, + EdgeAddedMutation, + EdgeRemovedMutation, + EdgeStatusChangedMutation, + AnnotationSetMutation, + AnnotationDeletedMutation, + ]), + z.null(), + ]), + new_value: z.discriminatedUnion("mutation_type", [ NodeAddedMutation, NodeRemovedMutation, NodeStatusChangedMutation, @@ -281,22 +382,10 @@ const RunGraphMutationDto = z.object({ AnnotationSetMutation, AnnotationDeletedMutation, ]), - z.null(), - ]), - new_value: z.discriminatedUnion("mutation_type", [ - NodeAddedMutation, - NodeRemovedMutation, - NodeStatusChangedMutation, - NodeFieldChangedMutation, - EdgeAddedMutation, - EdgeRemovedMutation, - EdgeStatusChangedMutation, - AnnotationSetMutation, - AnnotationDeletedMutation, - ]), - reason: z.union([z.string(), z.null()]), - created_at: z.string(), -}); + reason: z.union([z.string(), z.null()]), + created_at: z.string().datetime({ offset: true }), + }) + .passthrough(); const definition_id = z.union([z.string(), z.null()]).optional(); const TrainingCurvePointDto = z.object({ runId: z.string(), @@ -465,6 +554,16 @@ export const schemas = { RunTaskEvaluationDto, RunSandboxCommandDto, RunSandboxDto, + SystemPromptPayload, + UserMessagePayload, + JsonScalar, + JsonValue, + JsonObject, + TokenLogprob, + AssistantTextPayload, + ToolCallPayload, + ToolResultPayload, + ThinkingPayload, RunContextEventDto, RunCommunicationMessageDto, RunCommunicationThreadDto, @@ -478,12 +577,9 @@ export const schemas = { EdgeAddedMutation, EdgeRemovedMutation, EdgeStatusChangedMutation, - JsonScalar, - JsonValue, - JsonObject, AnnotationSetMutation, AnnotationDeletedMutation, - RunGraphMutationDto, + GraphMutationRecordDto, definition_id, TrainingCurvePointDto, TrainingSessionDto, diff --git a/ergon-dashboard/src/generated/rest/openapi.json b/ergon-dashboard/src/generated/rest/openapi.json index 5bdfa71d..b743949b 100644 --- a/ergon-dashboard/src/generated/rest/openapi.json +++ b/ergon-dashboard/src/generated/rest/openapi.json @@ -78,7 +78,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/RunGraphMutationDto" + "$ref": "#/components/schemas/GraphMutationRecordDto" }, "title": "Response Get Mutations Runs Run Id Mutations Get" } @@ -727,6 +727,61 @@ "title": "AnnotationSetMutation", "description": "annotation.set." }, + "AssistantTextPayload": { + "properties": { + "event_type": { + "type": "string", + "const": "assistant_text", + "title": "Event Type", + "default": "assistant_text" + }, + "text": { + "type": "string", + "title": "Text" + }, + "turn_id": { + "type": "string", + "title": "Turn Id", + "description": "Generation turn identifier that groups model-output events from the same single synchronous agent run." + }, + "turn_token_ids": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Turn Token Ids", + "description": "Token ids for the generation turn. Present only on the first model-output event so sibling events can share the turn-level token stream." + }, + "turn_logprobs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/TokenLogprob" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Turn Logprobs", + "description": "Token logprobs for the generation turn. Present only on the first model-output event so sibling events can share the turn-level logprob stream." + } + }, + "type": "object", + "required": [ + "text", + "turn_id" + ], + "title": "AssistantTextPayload" + }, "BatchStatus": { "type": "string", "enum": [ @@ -1111,10 +1166,12 @@ }, "source_node_id": { "type": "string", + "format": "uuid", "title": "Source Node Id" }, "target_node_id": { "type": "string", + "format": "uuid", "title": "Target Node Id" }, "status": { @@ -1141,10 +1198,12 @@ }, "source_node_id": { "type": "string", + "format": "uuid", "title": "Source Node Id" }, "target_node_id": { "type": "string", + "format": "uuid", "title": "Target Node Id" }, "status": { @@ -1209,6 +1268,189 @@ ], "title": "ExperimentCohortStatus" }, + "GraphMutationRecordDto": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id", + "description": "Identifier of the mutation row itself, not a graph target id." + }, + "run_id": { + "type": "string", + "format": "uuid", + "title": "Run Id" + }, + "sequence": { + "type": "integer", + "title": "Sequence" + }, + "mutation_type": { + "type": "string", + "enum": [ + "node.added", + "node.removed", + "node.status_changed", + "node.field_changed", + "edge.added", + "edge.removed", + "edge.status_changed", + "annotation.set", + "annotation.deleted" + ], + "title": "Mutation Type" + }, + "target_type": { + "type": "string", + "enum": [ + "node", + "edge" + ], + "title": "Target Type" + }, + "target_id": { + "type": "string", + "format": "uuid", + "title": "Target Id", + "description": "Polymorphic mutation target identifier. Interpreted as a NodeId, EdgeId, or annotation id based on target_type and mutation_type." + }, + "actor": { + "type": "string", + "title": "Actor" + }, + "old_value": { + "anyOf": [ + { + "oneOf": [ + { + "$ref": "#/components/schemas/NodeAddedMutation" + }, + { + "$ref": "#/components/schemas/NodeRemovedMutation" + }, + { + "$ref": "#/components/schemas/NodeStatusChangedMutation" + }, + { + "$ref": "#/components/schemas/NodeFieldChangedMutation" + }, + { + "$ref": "#/components/schemas/EdgeAddedMutation" + }, + { + "$ref": "#/components/schemas/EdgeRemovedMutation" + }, + { + "$ref": "#/components/schemas/EdgeStatusChangedMutation" + }, + { + "$ref": "#/components/schemas/AnnotationSetMutation" + }, + { + "$ref": "#/components/schemas/AnnotationDeletedMutation" + } + ], + "discriminator": { + "propertyName": "mutation_type", + "mapping": { + "annotation.deleted": "#/components/schemas/AnnotationDeletedMutation", + "annotation.set": "#/components/schemas/AnnotationSetMutation", + "edge.added": "#/components/schemas/EdgeAddedMutation", + "edge.removed": "#/components/schemas/EdgeRemovedMutation", + "edge.status_changed": "#/components/schemas/EdgeStatusChangedMutation", + "node.added": "#/components/schemas/NodeAddedMutation", + "node.field_changed": "#/components/schemas/NodeFieldChangedMutation", + "node.removed": "#/components/schemas/NodeRemovedMutation", + "node.status_changed": "#/components/schemas/NodeStatusChangedMutation" + } + } + }, + { + "type": "null" + } + ], + "title": "Old Value" + }, + "new_value": { + "oneOf": [ + { + "$ref": "#/components/schemas/NodeAddedMutation" + }, + { + "$ref": "#/components/schemas/NodeRemovedMutation" + }, + { + "$ref": "#/components/schemas/NodeStatusChangedMutation" + }, + { + "$ref": "#/components/schemas/NodeFieldChangedMutation" + }, + { + "$ref": "#/components/schemas/EdgeAddedMutation" + }, + { + "$ref": "#/components/schemas/EdgeRemovedMutation" + }, + { + "$ref": "#/components/schemas/EdgeStatusChangedMutation" + }, + { + "$ref": "#/components/schemas/AnnotationSetMutation" + }, + { + "$ref": "#/components/schemas/AnnotationDeletedMutation" + } + ], + "title": "New Value", + "discriminator": { + "propertyName": "mutation_type", + "mapping": { + "annotation.deleted": "#/components/schemas/AnnotationDeletedMutation", + "annotation.set": "#/components/schemas/AnnotationSetMutation", + "edge.added": "#/components/schemas/EdgeAddedMutation", + "edge.removed": "#/components/schemas/EdgeRemovedMutation", + "edge.status_changed": "#/components/schemas/EdgeStatusChangedMutation", + "node.added": "#/components/schemas/NodeAddedMutation", + "node.field_changed": "#/components/schemas/NodeFieldChangedMutation", + "node.removed": "#/components/schemas/NodeRemovedMutation", + "node.status_changed": "#/components/schemas/NodeStatusChangedMutation" + } + } + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "run_id", + "sequence", + "mutation_type", + "target_type", + "target_id", + "actor", + "old_value", + "new_value", + "reason", + "created_at" + ], + "title": "GraphMutationRecordDto", + "description": "Append-only graph mutation record with a typed mutation payload." + }, "HTTPValidationError": { "properties": { "detail": { @@ -1615,14 +1857,22 @@ "properties": { "id": { "type": "string", + "format": "uuid", "title": "Id" }, + "runId": { + "type": "string", + "format": "uuid", + "title": "Runid" + }, "taskExecutionId": { "type": "string", + "format": "uuid", "title": "Taskexecutionid" }, "taskNodeId": { "type": "string", + "format": "uuid", "title": "Tasknodeid" }, "workerBindingKey": { @@ -1635,21 +1885,60 @@ }, "eventType": { "type": "string", + "enum": [ + "system_prompt", + "user_message", + "assistant_text", + "tool_call", + "tool_result", + "thinking" + ], "title": "Eventtype" }, "payload": { - "additionalProperties": true, - "type": "object", - "title": "Payload" + "oneOf": [ + { + "$ref": "#/components/schemas/SystemPromptPayload" + }, + { + "$ref": "#/components/schemas/UserMessagePayload" + }, + { + "$ref": "#/components/schemas/AssistantTextPayload" + }, + { + "$ref": "#/components/schemas/ToolCallPayload" + }, + { + "$ref": "#/components/schemas/ToolResultPayload" + }, + { + "$ref": "#/components/schemas/ThinkingPayload" + } + ], + "title": "Payload", + "discriminator": { + "propertyName": "event_type", + "mapping": { + "assistant_text": "#/components/schemas/AssistantTextPayload", + "system_prompt": "#/components/schemas/SystemPromptPayload", + "thinking": "#/components/schemas/ThinkingPayload", + "tool_call": "#/components/schemas/ToolCallPayload", + "tool_result": "#/components/schemas/ToolResultPayload", + "user_message": "#/components/schemas/UserMessagePayload" + } + } }, "createdAt": { "type": "string", + "format": "date-time", "title": "Createdat" }, "startedAt": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "date-time" }, { "type": "null" @@ -1660,7 +1949,8 @@ "completedAt": { "anyOf": [ { - "type": "string" + "type": "string", + "format": "date-time" }, { "type": "null" @@ -1673,6 +1963,7 @@ "type": "object", "required": [ "id", + "runId", "taskExecutionId", "taskNodeId", "workerBindingKey", @@ -1701,6 +1992,10 @@ "type": "integer", "title": "Criterionnum" }, + "criterionSlug": { + "type": "string", + "title": "Criterionslug" + }, "criterionType": { "type": "string", "title": "Criteriontype" @@ -1801,6 +2096,18 @@ "type": "array", "title": "Evaluatedresourceids" }, + "observation": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Observation" + }, "error": { "anyOf": [ { @@ -1821,6 +2128,7 @@ "stageNum", "stageName", "criterionNum", + "criterionSlug", "criterionType", "criterionDescription", "criterionName", @@ -1960,178 +2268,15 @@ ], "title": "RunExecutionAttemptDto" }, - "RunGraphMutationDto": { + "RunResourceDto": { "properties": { "id": { "type": "string", "title": "Id" }, - "run_id": { + "taskId": { "type": "string", - "title": "Run Id" - }, - "sequence": { - "type": "integer", - "title": "Sequence" - }, - "mutation_type": { - "type": "string", - "title": "Mutation Type" - }, - "target_type": { - "type": "string", - "title": "Target Type" - }, - "target_id": { - "type": "string", - "title": "Target Id" - }, - "actor": { - "type": "string", - "title": "Actor" - }, - "old_value": { - "anyOf": [ - { - "oneOf": [ - { - "$ref": "#/components/schemas/NodeAddedMutation" - }, - { - "$ref": "#/components/schemas/NodeRemovedMutation" - }, - { - "$ref": "#/components/schemas/NodeStatusChangedMutation" - }, - { - "$ref": "#/components/schemas/NodeFieldChangedMutation" - }, - { - "$ref": "#/components/schemas/EdgeAddedMutation" - }, - { - "$ref": "#/components/schemas/EdgeRemovedMutation" - }, - { - "$ref": "#/components/schemas/EdgeStatusChangedMutation" - }, - { - "$ref": "#/components/schemas/AnnotationSetMutation" - }, - { - "$ref": "#/components/schemas/AnnotationDeletedMutation" - } - ], - "discriminator": { - "propertyName": "mutation_type", - "mapping": { - "annotation.deleted": "#/components/schemas/AnnotationDeletedMutation", - "annotation.set": "#/components/schemas/AnnotationSetMutation", - "edge.added": "#/components/schemas/EdgeAddedMutation", - "edge.removed": "#/components/schemas/EdgeRemovedMutation", - "edge.status_changed": "#/components/schemas/EdgeStatusChangedMutation", - "node.added": "#/components/schemas/NodeAddedMutation", - "node.field_changed": "#/components/schemas/NodeFieldChangedMutation", - "node.removed": "#/components/schemas/NodeRemovedMutation", - "node.status_changed": "#/components/schemas/NodeStatusChangedMutation" - } - } - }, - { - "type": "null" - } - ], - "title": "Old Value" - }, - "new_value": { - "oneOf": [ - { - "$ref": "#/components/schemas/NodeAddedMutation" - }, - { - "$ref": "#/components/schemas/NodeRemovedMutation" - }, - { - "$ref": "#/components/schemas/NodeStatusChangedMutation" - }, - { - "$ref": "#/components/schemas/NodeFieldChangedMutation" - }, - { - "$ref": "#/components/schemas/EdgeAddedMutation" - }, - { - "$ref": "#/components/schemas/EdgeRemovedMutation" - }, - { - "$ref": "#/components/schemas/EdgeStatusChangedMutation" - }, - { - "$ref": "#/components/schemas/AnnotationSetMutation" - }, - { - "$ref": "#/components/schemas/AnnotationDeletedMutation" - } - ], - "title": "New Value", - "discriminator": { - "propertyName": "mutation_type", - "mapping": { - "annotation.deleted": "#/components/schemas/AnnotationDeletedMutation", - "annotation.set": "#/components/schemas/AnnotationSetMutation", - "edge.added": "#/components/schemas/EdgeAddedMutation", - "edge.removed": "#/components/schemas/EdgeRemovedMutation", - "edge.status_changed": "#/components/schemas/EdgeStatusChangedMutation", - "node.added": "#/components/schemas/NodeAddedMutation", - "node.field_changed": "#/components/schemas/NodeFieldChangedMutation", - "node.removed": "#/components/schemas/NodeRemovedMutation", - "node.status_changed": "#/components/schemas/NodeStatusChangedMutation" - } - } - }, - "reason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Reason" - }, - "created_at": { - "type": "string", - "title": "Created At" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "id", - "run_id", - "sequence", - "mutation_type", - "target_type", - "target_id", - "actor", - "old_value", - "new_value", - "reason", - "created_at" - ], - "title": "RunGraphMutationDto", - "description": "One entry in the append-only mutation log for a run.\n\nField names are snake_case to match the frontend GraphMutationDtoSchema.\nCamelModel is intentionally not used here \u2014 the frontend contract uses snake_case." - }, - "RunResourceDto": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "taskId": { - "type": "string", - "title": "Taskid" + "title": "Taskid" }, "taskExecutionId": { "type": "string", @@ -2554,7 +2699,7 @@ ], "title": "Assignedworkerid" }, - "assignedWorkerName": { + "assignedWorkerSlug": { "anyOf": [ { "type": "string" @@ -2563,7 +2708,7 @@ "type": "null" } ], - "title": "Assignedworkername" + "title": "Assignedworkerslug" }, "startedAt": { "anyOf": [ @@ -2600,7 +2745,8 @@ "isLeaf", "level" ], - "title": "RunTaskDto" + "title": "RunTaskDto", + "description": "REST projection of RunGraphNode for run detail pages.\n\nThis is not the canonical graph schema; graph semantics live in\nruntime/services/graph_dto.py and persistence/graph/status_conventions.py." }, "RunTaskEvaluationDto": { "properties": { @@ -2762,6 +2908,207 @@ "title": "SubmitResponse", "description": "Ergon \u2192 Trainer: batch accepted." }, + "SystemPromptPayload": { + "properties": { + "event_type": { + "type": "string", + "const": "system_prompt", + "title": "Event Type", + "default": "system_prompt" + }, + "text": { + "type": "string", + "title": "Text" + } + }, + "type": "object", + "required": [ + "text" + ], + "title": "SystemPromptPayload" + }, + "ThinkingPayload": { + "properties": { + "event_type": { + "type": "string", + "const": "thinking", + "title": "Event Type", + "default": "thinking" + }, + "text": { + "type": "string", + "title": "Text" + }, + "turn_id": { + "type": "string", + "title": "Turn Id", + "description": "Generation turn identifier that groups thinking text with other events from the same single synchronous agent run." + }, + "turn_token_ids": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Turn Token Ids", + "description": "Token ids for the generation turn. Present only on the first model-output event so sibling events can share the turn-level token stream." + }, + "turn_logprobs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/TokenLogprob" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Turn Logprobs", + "description": "Token logprobs for the generation turn. Present only on the first model-output event so sibling events can share the turn-level logprob stream." + } + }, + "type": "object", + "required": [ + "text", + "turn_id" + ], + "title": "ThinkingPayload" + }, + "TokenLogprob": { + "properties": { + "token": { + "type": "string", + "title": "Token" + }, + "logprob": { + "type": "number", + "title": "Logprob" + }, + "top_logprobs": { + "items": { + "$ref": "#/components/schemas/JsonObject" + }, + "type": "array", + "title": "Top Logprobs" + } + }, + "type": "object", + "required": [ + "token", + "logprob" + ], + "title": "TokenLogprob", + "description": "Per-token log probability from the serving backend." + }, + "ToolCallPayload": { + "properties": { + "event_type": { + "type": "string", + "const": "tool_call", + "title": "Event Type", + "default": "tool_call" + }, + "tool_call_id": { + "type": "string", + "title": "Tool Call Id" + }, + "tool_name": { + "type": "string", + "title": "Tool Name" + }, + "args": { + "additionalProperties": true, + "type": "object", + "title": "Args" + }, + "turn_id": { + "type": "string", + "title": "Turn Id", + "description": "Generation turn identifier that groups this tool call with other events emitted by the same single synchronous agent run." + }, + "turn_token_ids": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Turn Token Ids", + "description": "Token ids for the generation turn, omitted when another event in this turn carries the shared token stream." + }, + "turn_logprobs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/TokenLogprob" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Turn Logprobs", + "description": "Token logprobs for the generation turn, omitted when another event in this turn carries the shared logprob stream." + } + }, + "type": "object", + "required": [ + "tool_call_id", + "tool_name", + "args", + "turn_id" + ], + "title": "ToolCallPayload" + }, + "ToolResultPayload": { + "properties": { + "event_type": { + "type": "string", + "const": "tool_result", + "title": "Event Type", + "default": "tool_result" + }, + "tool_call_id": { + "type": "string", + "title": "Tool Call Id", + "description": "Identifier linking this result back to the ToolCallPayload it answers." + }, + "tool_name": { + "type": "string", + "title": "Tool Name" + }, + "result": { + "title": "Result", + "description": "Open JSON-serializable value returned by the tool call; intentionally accepts any persisted result shape." + }, + "is_error": { + "type": "boolean", + "title": "Is Error", + "default": false + } + }, + "type": "object", + "required": [ + "tool_call_id", + "tool_name", + "result" + ], + "title": "ToolResultPayload" + }, "TrainingCurvePointDto": { "properties": { "runId": { @@ -3080,6 +3427,37 @@ "title": "UpdateCohortRequest", "description": "Mutable cohort fields exposed through the operator API." }, + "UserMessagePayload": { + "properties": { + "event_type": { + "type": "string", + "const": "user_message", + "title": "Event Type", + "default": "user_message" + }, + "text": { + "type": "string", + "title": "Text" + }, + "from_worker_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "From Worker Key", + "description": "Worker binding key when this message was sent by another agent instead of the external user." + } + }, + "type": "object", + "required": [ + "text" + ], + "title": "UserMessagePayload" + }, "ValidationError": { "properties": { "loc": { diff --git a/ergon-dashboard/src/inngest/functions/onContextEvent.ts b/ergon-dashboard/src/inngest/functions/onContextEvent.ts index 0a3113ac..9e5278bd 100644 --- a/ergon-dashboard/src/inngest/functions/onContextEvent.ts +++ b/ergon-dashboard/src/inngest/functions/onContextEvent.ts @@ -12,6 +12,7 @@ export const onContextEvent = inngest.createFunction( const contextEvent: ContextEventState = { id: payload.id, + runId: payload.run_id, taskExecutionId: payload.task_execution_id, taskNodeId: payload.task_node_id, workerBindingKey: payload.worker_binding_key, diff --git a/ergon-dashboard/src/lib/contracts/contextEvents.ts b/ergon-dashboard/src/lib/contracts/contextEvents.ts index 1fc1ec01..466a7df4 100644 --- a/ergon-dashboard/src/lib/contracts/contextEvents.ts +++ b/ergon-dashboard/src/lib/contracts/contextEvents.ts @@ -12,9 +12,19 @@ export type ContextEventType = | "tool_result" | "thinking"; +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + export interface TokenLogprob { + [key: string]: JsonValue | undefined; token: string; logprob: number; + top_logprobs?: Record[]; } export type ContextEventPayload = @@ -53,6 +63,7 @@ export type ContextEventPayload = export interface ContextEventState { id: string; + runId: string; taskExecutionId: string; taskNodeId: string; workerBindingKey: string; diff --git a/ergon-dashboard/src/lib/contracts/events.ts b/ergon-dashboard/src/lib/contracts/events.ts index 55c0b2ef..945df019 100644 --- a/ergon-dashboard/src/lib/contracts/events.ts +++ b/ergon-dashboard/src/lib/contracts/events.ts @@ -66,6 +66,7 @@ export type TaskTreeNode = { id: string; name: string; description: string; + assigned_worker_slug?: string | null; assigned_to: WorkerRef; full_team?: WorkerRef[] | null; children: TaskTreeNode[]; @@ -81,6 +82,7 @@ export const TaskTreeNodeSchema: z.ZodType<{ id: string; name: string; description: string; + assigned_worker_slug?: string | null; assigned_to: WorkerRef; full_team?: WorkerRef[] | null; children: TaskTreeNode[]; @@ -95,6 +97,7 @@ export const TaskTreeNodeSchema: z.ZodType<{ id: z.string().uuid(), name: z.string(), description: z.string(), + assigned_worker_slug: z.string().nullable().optional(), assigned_to: WorkerRefSchema, full_team: z.array(WorkerRefSchema).nullable().optional(), children: z.array(TaskTreeNodeSchema), @@ -160,7 +163,7 @@ export const TaskStatusSocketDataSchema = z.object({ status: TaskStatusSchema, timestamp: z.string(), assignedWorkerId: z.string().nullable(), - assignedWorkerName: z.string().nullable(), + assignedWorkerSlug: z.string().nullable(), }); export const ResourceSocketDataSchema = z.object({ runId: z.string(), diff --git a/ergon-dashboard/src/lib/contracts/rest.ts b/ergon-dashboard/src/lib/contracts/rest.ts index cc596656..ff4a6d6f 100644 --- a/ergon-dashboard/src/lib/contracts/rest.ts +++ b/ergon-dashboard/src/lib/contracts/rest.ts @@ -172,10 +172,10 @@ export interface RunSandbox export interface RunTask extends Omit< RawRunTask, - "assignedWorkerId" | "assignedWorkerName" | "childIds" | "completedAt" | "dependsOnIds" | "parentId" | "startedAt" + "assignedWorkerId" | "assignedWorkerSlug" | "childIds" | "completedAt" | "dependsOnIds" | "parentId" | "startedAt" > { assignedWorkerId: string | null; - assignedWorkerName: string | null; + assignedWorkerSlug: string | null; childIds: string[]; /** Terminal wall time when set; null until finished or if the task never started. */ completedAt: string | null; @@ -313,7 +313,7 @@ function normalizeRunTask(task: RawRunTask): RunTask { return { ...task, assignedWorkerId: task.assignedWorkerId ?? null, - assignedWorkerName: task.assignedWorkerName ?? null, + assignedWorkerSlug: task.assignedWorkerSlug ?? null, childIds: task.childIds ?? [], completedAt: task.completedAt ?? null, dependsOnIds: task.dependsOnIds ?? [], diff --git a/ergon-dashboard/src/lib/runState.ts b/ergon-dashboard/src/lib/runState.ts index 04c53a8f..35a4bfab 100644 --- a/ergon-dashboard/src/lib/runState.ts +++ b/ergon-dashboard/src/lib/runState.ts @@ -78,6 +78,7 @@ function deserializeContextEvents(data: RunSnapshot): Map ({ id: String(event.id ?? ""), + runId: String(event.runId ?? data.id), taskExecutionId: String(event.taskExecutionId ?? ""), taskNodeId: String(event.taskNodeId ?? taskId), workerBindingKey: String(event.workerBindingKey ?? ""), diff --git a/ergon-dashboard/tests/contracts/contracts.test.ts b/ergon-dashboard/tests/contracts/contracts.test.ts index 239df004..af54d142 100644 --- a/ergon-dashboard/tests/contracts/contracts.test.ts +++ b/ergon-dashboard/tests/contracts/contracts.test.ts @@ -23,7 +23,8 @@ test("run snapshot parser accepts object-map transport", () => { FIXTURE_IDS.exploreTaskId, FIXTURE_IDS.rootTaskId, FIXTURE_IDS.solveTaskId, - ]); + ].sort()); + assert.equal(parsed.tasks?.[FIXTURE_IDS.solveTaskId]?.assignedWorkerSlug, "react-worker"); }); test("run snapshot hydration preserves context event actions", () => { @@ -57,7 +58,7 @@ test("run snapshot hydration orders context events across retried executions", ( const retryEvent = { ...first, id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", - taskExecutionId: "execution-solve-2", + taskExecutionId: "20000000-0000-4000-8000-000000000003", sequence: 0, createdAt: "2026-03-18T12:00:30.000Z", payload: { @@ -117,6 +118,7 @@ test("workflow started event parser validates recursive task trees", () => { id: "123e4567-e89b-42d3-a456-426614174000", name: "Root", description: "Root task", + assigned_worker_slug: "planner", assigned_to: { id: FIXTURE_IDS.workerId, name: "planner", @@ -128,6 +130,7 @@ test("workflow started event parser validates recursive task trees", () => { id: "123e4567-e89b-42d3-a456-426614174001", name: "Leaf", description: "Leaf task", + assigned_worker_slug: "planner", assigned_to: { id: FIXTURE_IDS.workerId, name: "planner", @@ -155,6 +158,20 @@ test("workflow started event parser validates recursive task trees", () => { const parsed = parseDashboardWorkflowStartedData(payload); assert.equal(parsed.task_tree.children[0]?.name, "Leaf"); + assert.equal(parsed.task_tree.children[0]?.assigned_worker_slug, "planner"); +}); + +test("socket task status parser accepts assigned worker slugs", () => { + const parsed = parseTaskStatusSocketData({ + runId: FIXTURE_IDS.runId, + taskId: FIXTURE_IDS.solveTaskId, + status: "running", + timestamp: "2026-03-18T12:00:14.000Z", + assignedWorkerId: FIXTURE_IDS.workerId, + assignedWorkerSlug: "react-worker", + }); + + assert.equal(parsed.assignedWorkerSlug, "react-worker"); }); test("dashboard nested DTO event parser accepts backend snake-case payloads", () => { @@ -232,7 +249,7 @@ test("socket task status parser rejects malformed payloads", () => { taskId: FIXTURE_IDS.solveTaskId, timestamp: "2026-03-18T12:00:14.000Z", assignedWorkerId: FIXTURE_IDS.workerId, - assignedWorkerName: "react-worker", + assignedWorkerSlug: "react-worker", }), ); }); diff --git a/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json b/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json index 5f94ce97..94088c0f 100644 --- a/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json +++ b/ergon-dashboard/tests/fixtures/mas-runs/concurrent-mas-run.json @@ -33,7 +33,7 @@ "isLeaf": false, "level": 0, "assignedWorkerId": null, - "assignedWorkerName": "planner", + "assignedWorkerSlug": "planner", "startedAt": "2026-04-26T12:00:00.000Z", "completedAt": null }, @@ -48,7 +48,7 @@ "isLeaf": true, "level": 1, "assignedWorkerId": null, - "assignedWorkerName": "researcher-a", + "assignedWorkerSlug": "researcher-a", "startedAt": "2026-04-26T12:00:05.000Z", "completedAt": null }, @@ -63,7 +63,7 @@ "isLeaf": true, "level": 1, "assignedWorkerId": null, - "assignedWorkerName": "researcher-b", + "assignedWorkerSlug": "researcher-b", "startedAt": "2026-04-26T12:00:08.000Z", "completedAt": null }, @@ -84,7 +84,7 @@ "isLeaf": false, "level": 1, "assignedWorkerId": null, - "assignedWorkerName": "writer", + "assignedWorkerSlug": "writer", "startedAt": null, "completedAt": null }, @@ -99,7 +99,7 @@ "isLeaf": true, "level": 2, "assignedWorkerId": null, - "assignedWorkerName": "writer-a", + "assignedWorkerSlug": "writer-a", "startedAt": null, "completedAt": null }, @@ -114,7 +114,7 @@ "isLeaf": true, "level": 2, "assignedWorkerId": null, - "assignedWorkerName": "writer-b", + "assignedWorkerSlug": "writer-b", "startedAt": "2026-04-26T12:00:18.000Z", "completedAt": "2026-04-26T12:00:25.000Z" } @@ -257,6 +257,7 @@ "stageNum": 0, "stageName": "citation_validation", "criterionNum": 0, + "criterionSlug": "citations_validate", "criterionType": "code_rule", "criterionDescription": "Citations validate", "criterionName": "citations validate", @@ -281,6 +282,7 @@ "10000000-0000-4000-8000-000000000002": [ { "id": "60000000-0000-4000-8000-000000000001", + "runId": "99999999-9999-4999-8999-999999999999", "taskExecutionId": "30000000-0000-4000-8000-000000000001", "taskNodeId": "10000000-0000-4000-8000-000000000002", "workerBindingKey": "researcher-a", @@ -288,10 +290,14 @@ "eventType": "tool_call", "payload": { "event_type": "tool_call", + "tool_call_id": "call-search-mas-layout", "tool_name": "search", "args": { "query": "MAS layout" - } + }, + "turn_id": "turn-search", + "turn_token_ids": null, + "turn_logprobs": null }, "createdAt": "2026-04-26T12:00:10.000Z", "startedAt": "2026-04-26T12:00:10.000Z", diff --git a/ergon-dashboard/tests/helpers/dashboardFixtures.ts b/ergon-dashboard/tests/helpers/dashboardFixtures.ts index b91090cd..fc98d269 100644 --- a/ergon-dashboard/tests/helpers/dashboardFixtures.ts +++ b/ergon-dashboard/tests/helpers/dashboardFixtures.ts @@ -13,9 +13,11 @@ export const FIXTURE_IDS = { cohortId: "11111111-1111-4111-8111-111111111111", runId: "22222222-2222-4222-8222-222222222222", experimentId: "33333333-3333-4333-8333-333333333333", - rootTaskId: "task-root", - exploreTaskId: "task-explore", - solveTaskId: "task-solve", + rootTaskId: "10000000-0000-4000-8000-000000000001", + exploreTaskId: "10000000-0000-4000-8000-000000000002", + solveTaskId: "10000000-0000-4000-8000-000000000003", + exploreExecutionId: "20000000-0000-4000-8000-000000000001", + solveExecutionId: "20000000-0000-4000-8000-000000000002", actionId: "44444444-4444-4444-8444-444444444444", workerId: "55555555-5555-4555-8555-555555555555", threadId: "66666666-6666-4666-8666-666666666666", @@ -42,7 +44,7 @@ function taskState(task: Partial & Pick Date: Tue, 28 Apr 2026 16:16:01 +0100 Subject: [PATCH 32/66] Use worker slug in dashboard task state Made-with: Cursor --- .../src/components/dag/DAGCanvas.tsx | 2 +- .../src/components/workspace/TaskWorkspace.tsx | 2 +- .../src/features/graph/components/LeafNode.tsx | 2 +- .../graph/contracts/graphMutations.test.ts | 18 +++++++++--------- .../graph/layout/hierarchicalLayout.ts | 2 +- .../graph/state/graphMutationReducer.ts | 6 +++--- ergon-dashboard/src/hooks/useRunState.ts | 6 +++--- ergon-dashboard/src/inngest/functions/index.ts | 6 +++--- ergon-dashboard/src/lib/socket/server.ts | 4 ++-- ergon-dashboard/src/lib/state/store.ts | 14 +++++++------- .../src/lib/testing/dashboardHarness.ts | 6 +++--- ergon-dashboard/src/lib/types.ts | 2 +- 12 files changed, 35 insertions(+), 35 deletions(-) diff --git a/ergon-dashboard/src/components/dag/DAGCanvas.tsx b/ergon-dashboard/src/components/dag/DAGCanvas.tsx index 10da6eae..a2212144 100644 --- a/ergon-dashboard/src/components/dag/DAGCanvas.tsx +++ b/ergon-dashboard/src/components/dag/DAGCanvas.tsx @@ -305,7 +305,7 @@ function DAGCanvasInner({ if ( task.name.toLowerCase().includes(searchLower) || task.description?.toLowerCase().includes(searchLower) || - task.assignedWorkerName?.toLowerCase().includes(searchLower) + task.assignedWorkerSlug?.toLowerCase().includes(searchLower) ) { count++; } diff --git a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx index 139e637c..2595b504 100644 --- a/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx +++ b/ergon-dashboard/src/components/workspace/TaskWorkspace.tsx @@ -282,7 +282,7 @@ export function TaskWorkspace({ )}
- Worker: {task.assignedWorkerName ?? "—"} + Worker: {task.assignedWorkerSlug ?? "—"} Level: {task.level} Leaf task: {task.isLeaf ? "yes" : "no"} Attempts: {filteredEvidence.executions.length || 0} diff --git a/ergon-dashboard/src/features/graph/components/LeafNode.tsx b/ergon-dashboard/src/features/graph/components/LeafNode.tsx index b1ca0a66..f916e1f9 100644 --- a/ergon-dashboard/src/features/graph/components/LeafNode.tsx +++ b/ergon-dashboard/src/features/graph/components/LeafNode.tsx @@ -122,7 +122,7 @@ function LeafNodeComponent(props: LeafNodeProps) { const statusLabel = task.status === ("running" as TaskStatus) - ? `running${task.assignedWorkerName ? ` · ${task.assignedWorkerName}` : ""}` + ? `running${task.assignedWorkerSlug ? ` · ${task.assignedWorkerSlug}` : ""}` : task.status; return ( diff --git a/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts b/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts index 8225a7e1..c8fbdb22 100644 --- a/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts +++ b/ergon-dashboard/src/features/graph/contracts/graphMutations.test.ts @@ -147,7 +147,7 @@ test("replay base preserves snapshot hierarchy while dependency edges remain dep ], dependsOnIds: [], assignedWorkerId: null, - assignedWorkerName: "parent", + assignedWorkerSlug: "parent", startedAt: "2026-04-26T12:00:00.000Z", completedAt: null, isLeaf: false, @@ -165,7 +165,7 @@ test("replay base preserves snapshot hierarchy while dependency edges remain dep childIds: [], dependsOnIds: [], assignedWorkerId: null, - assignedWorkerName: "worker-a", + assignedWorkerSlug: "worker-a", startedAt: "2026-04-26T12:00:01.000Z", completedAt: "2026-04-26T12:00:05.000Z", isLeaf: true, @@ -183,7 +183,7 @@ test("replay base preserves snapshot hierarchy while dependency edges remain dep childIds: [], dependsOnIds: ["22222222-2222-4222-8222-222222222222"], assignedWorkerId: "future-agent-id", - assignedWorkerName: "worker-b", + assignedWorkerSlug: "worker-b", startedAt: "2026-04-26T12:00:06.000Z", completedAt: null, isLeaf: true, @@ -245,7 +245,7 @@ test("replay base does not leak future dependency edges or node field changes", ], dependsOnIds: [], assignedWorkerId: null, - assignedWorkerName: "parent", + assignedWorkerSlug: "parent", startedAt: "2026-04-26T12:00:00.000Z", completedAt: null, isLeaf: false, @@ -263,7 +263,7 @@ test("replay base does not leak future dependency edges or node field changes", childIds: [], dependsOnIds: [], assignedWorkerId: "future-agent-id", - assignedWorkerName: "future-worker", + assignedWorkerSlug: "future-worker", startedAt: null, completedAt: null, isLeaf: true, @@ -281,7 +281,7 @@ test("replay base does not leak future dependency edges or node field changes", childIds: [], dependsOnIds: ["22222222-2222-4222-8222-222222222222"], assignedWorkerId: null, - assignedWorkerName: "worker-b", + assignedWorkerSlug: "worker-b", startedAt: null, completedAt: null, isLeaf: true, @@ -336,7 +336,7 @@ test("replay base does not leak future dependency edges or node field changes", const target = replayed.tasks.get("33333333-3333-4333-8333-333333333333"); assert.equal(source?.description, "source"); assert.equal(source?.assignedWorkerId, null); - assert.equal(source?.assignedWorkerName, "worker"); + assert.equal(source?.assignedWorkerSlug, "worker"); assert.deepEqual(target?.dependsOnIds, []); }); @@ -354,7 +354,7 @@ test("dependency edges between root-level tasks do not become containment", () = childIds: [], dependsOnIds: [], assignedWorkerId: null, - assignedWorkerName: "worker-a", + assignedWorkerSlug: "worker-a", startedAt: null, completedAt: null, isLeaf: true, @@ -372,7 +372,7 @@ test("dependency edges between root-level tasks do not become containment", () = childIds: [], dependsOnIds: ["22222222-2222-4222-8222-222222222222"], assignedWorkerId: null, - assignedWorkerName: "worker-b", + assignedWorkerSlug: "worker-b", startedAt: null, completedAt: null, isLeaf: true, diff --git a/ergon-dashboard/src/features/graph/layout/hierarchicalLayout.ts b/ergon-dashboard/src/features/graph/layout/hierarchicalLayout.ts index a7a897eb..fd423604 100644 --- a/ergon-dashboard/src/features/graph/layout/hierarchicalLayout.ts +++ b/ergon-dashboard/src/features/graph/layout/hierarchicalLayout.ts @@ -257,7 +257,7 @@ export function computeHierarchicalLayout( if ( task.name.toLowerCase().includes(searchLower) || task.description?.toLowerCase().includes(searchLower) || - task.assignedWorkerName?.toLowerCase().includes(searchLower) + task.assignedWorkerSlug?.toLowerCase().includes(searchLower) ) { matchingNodeIds.add(task.id); } diff --git a/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts b/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts index a705dcb2..7c56e990 100644 --- a/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts +++ b/ergon-dashboard/src/features/graph/state/graphMutationReducer.ts @@ -208,7 +208,7 @@ function applyNodeAdded( childIds: [], dependsOnIds: [], assignedWorkerId: null, - assignedWorkerName: value.assigned_worker_slug, + assignedWorkerSlug: value.assigned_worker_slug, startedAt: null, completedAt: null, isLeaf: true, @@ -277,7 +277,7 @@ function applyNodeFieldChange( updated.description = value.value ?? ""; break; case "assigned_worker_slug": - updated.assignedWorkerName = value.value; + updated.assignedWorkerSlug = value.value; break; } @@ -524,7 +524,7 @@ export function createReplayInitialState( description: initialValue?.description ?? task.description, status: (initialValue?.status as TaskStatus | undefined) ?? task.status, assignedWorkerId: null, - assignedWorkerName: initialValue?.assigned_worker_slug ?? null, + assignedWorkerSlug: initialValue?.assigned_worker_slug ?? null, parentId, childIds, dependsOnIds: [], diff --git a/ergon-dashboard/src/hooks/useRunState.ts b/ergon-dashboard/src/hooks/useRunState.ts index 0c217ecd..5264bd0e 100644 --- a/ergon-dashboard/src/hooks/useRunState.ts +++ b/ergon-dashboard/src/hooks/useRunState.ts @@ -170,7 +170,7 @@ export function useRunState( ...task, status, assignedWorkerId: data.assignedWorkerId ?? task.assignedWorkerId, - assignedWorkerName: data.assignedWorkerName ?? task.assignedWorkerName, + assignedWorkerSlug: data.assignedWorkerSlug ?? task.assignedWorkerSlug, startedAt: status === TaskStatus.RUNNING && !task.startedAt ? data.timestamp @@ -201,7 +201,7 @@ export function useRunState( attemptNumber: existingExecutions.length + 1, status: TaskStatus.RUNNING, agentId: data.assignedWorkerId, - agentName: data.assignedWorkerName, + agentName: data.assignedWorkerSlug, startedAt: data.timestamp, completedAt: null, finalAssistantMessage: null, @@ -218,7 +218,7 @@ export function useRunState( ...execution, status: TaskStatus.RUNNING, agentId: data.assignedWorkerId ?? execution.agentId, - agentName: data.assignedWorkerName ?? execution.agentName, + agentName: data.assignedWorkerSlug ?? execution.agentName, startedAt: execution.startedAt ?? data.timestamp, } : execution, diff --git a/ergon-dashboard/src/inngest/functions/index.ts b/ergon-dashboard/src/inngest/functions/index.ts index b2e7f13a..b4b4a267 100644 --- a/ergon-dashboard/src/inngest/functions/index.ts +++ b/ergon-dashboard/src/inngest/functions/index.ts @@ -193,7 +193,7 @@ const onTaskStatusChanged = inngest.createFunction( new_status, timestamp, assigned_worker_id, - assigned_worker_name, + assigned_worker_slug, } = payload; console.log("[Dashboard] Task status changed:", { @@ -210,7 +210,7 @@ const onTaskStatusChanged = inngest.createFunction( new_status as TaskStatus, timestamp, assigned_worker_id ?? null, - assigned_worker_name ?? null + assigned_worker_slug ?? null ); // Broadcast to run subscribers @@ -220,7 +220,7 @@ const onTaskStatusChanged = inngest.createFunction( new_status as TaskStatus, timestamp, assigned_worker_id ?? null, - assigned_worker_name ?? null + assigned_worker_slug ?? null ); return { success: true }; diff --git a/ergon-dashboard/src/lib/socket/server.ts b/ergon-dashboard/src/lib/socket/server.ts index a34107c6..60f3373f 100644 --- a/ergon-dashboard/src/lib/socket/server.ts +++ b/ergon-dashboard/src/lib/socket/server.ts @@ -196,7 +196,7 @@ export function broadcastTaskStatus( status: TaskStatus, timestamp: string, assignedWorkerId: string | null, - assignedWorkerName: string | null + assignedWorkerSlug: string | null ): void { const io = getIO(); io?.to(`run:${runId}`).emit("task:status", { @@ -205,7 +205,7 @@ export function broadcastTaskStatus( status, timestamp, assignedWorkerId, - assignedWorkerName, + assignedWorkerSlug, }); } diff --git a/ergon-dashboard/src/lib/state/store.ts b/ergon-dashboard/src/lib/state/store.ts index 2ac40ad7..1745d512 100644 --- a/ergon-dashboard/src/lib/state/store.ts +++ b/ergon-dashboard/src/lib/state/store.ts @@ -174,7 +174,7 @@ class DashboardStore { newStatus: TaskStatus, timestamp: string, assignedWorkerId?: string | null, - assignedWorkerName?: string | null + assignedWorkerSlug?: string | null ): void { const run = this.runs.get(runId); const task = run?.tasks.get(taskId); @@ -197,7 +197,7 @@ class DashboardStore { attemptNumber: executions.length + 1, status: TaskStatus.RUNNING, agentId: assignedWorkerId ?? task.assignedWorkerId, - agentName: assignedWorkerName ?? task.assignedWorkerName, + agentName: assignedWorkerSlug ?? task.assignedWorkerSlug, startedAt: timestamp, completedAt: null, finalAssistantMessage: null, @@ -211,7 +211,7 @@ class DashboardStore { latestExecution.status = TaskStatus.RUNNING; latestExecution.startedAt = latestExecution.startedAt ?? timestamp; latestExecution.agentId = assignedWorkerId ?? latestExecution.agentId; - latestExecution.agentName = assignedWorkerName ?? latestExecution.agentName; + latestExecution.agentName = assignedWorkerSlug ?? latestExecution.agentName; } } else if (latestExecution) { latestExecution.status = nextExecutionStatus; @@ -234,7 +234,7 @@ class DashboardStore { trigger, at: timestamp, sequence: null, - actor: assignedWorkerName ?? task.assignedWorkerName ?? null, + actor: assignedWorkerSlug ?? task.assignedWorkerSlug ?? null, reason: null, }; task.history = [...(task.history ?? []), record]; @@ -244,8 +244,8 @@ class DashboardStore { if (assignedWorkerId !== undefined) { task.assignedWorkerId = assignedWorkerId; } - if (assignedWorkerName !== undefined) { - task.assignedWorkerName = assignedWorkerName; + if (assignedWorkerSlug !== undefined) { + task.assignedWorkerSlug = assignedWorkerSlug; } // Update timestamps @@ -439,7 +439,7 @@ class DashboardStore { childIds: tree.children.map((c) => c.id), dependsOnIds: tree.depends_on, assignedWorkerId: tree.assigned_to?.id ?? null, - assignedWorkerName: tree.assigned_to?.name ?? null, + assignedWorkerSlug: tree.assigned_worker_slug ?? null, startedAt: null, completedAt: null, isLeaf: tree.is_leaf, diff --git a/ergon-dashboard/src/lib/testing/dashboardHarness.ts b/ergon-dashboard/src/lib/testing/dashboardHarness.ts index 16955fcb..70bb63c3 100644 --- a/ergon-dashboard/src/lib/testing/dashboardHarness.ts +++ b/ergon-dashboard/src/lib/testing/dashboardHarness.ts @@ -206,7 +206,7 @@ export function emitHarnessTaskStatus(data: { taskId: string; status: TaskStatus; assignedWorkerId?: string | null; - assignedWorkerName?: string | null; + assignedWorkerSlug?: string | null; }): void { requireHarnessEnabled(); store.updateTaskStatus( @@ -215,7 +215,7 @@ export function emitHarnessTaskStatus(data: { data.status, new Date().toISOString(), data.assignedWorkerId, - data.assignedWorkerName, + data.assignedWorkerSlug, ); broadcastTaskStatus( data.runId, @@ -223,7 +223,7 @@ export function emitHarnessTaskStatus(data: { data.status, new Date().toISOString(), data.assignedWorkerId ?? null, - data.assignedWorkerName ?? null, + data.assignedWorkerSlug ?? null, ); } diff --git a/ergon-dashboard/src/lib/types.ts b/ergon-dashboard/src/lib/types.ts index 8e7e9e02..0a99613e 100644 --- a/ergon-dashboard/src/lib/types.ts +++ b/ergon-dashboard/src/lib/types.ts @@ -184,7 +184,7 @@ export interface TaskState { childIds: string[]; dependsOnIds: string[]; assignedWorkerId: string | null; - assignedWorkerName: string | null; + assignedWorkerSlug: string | null; /** From run snapshot `startedAt`: null only before the task has actually started. */ startedAt: string | null; /** From run snapshot `completedAt`: null until the task finishes (or never started). */ From 3b0c8389ddb2858be1fe19bf699a4320fadbe2ee Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:38:15 +0100 Subject: [PATCH 33/66] docs: audit runtime service layout Capture service ownership and layout concerns discovered during schema cleanup for a follow-up refactor. Made-with: Cursor --- ...026-04-28-runtime-services-layout-audit.md | 527 ++++++++++++++++++ 1 file changed, 527 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-28-runtime-services-layout-audit.md diff --git a/docs/superpowers/plans/2026-04-28-runtime-services-layout-audit.md b/docs/superpowers/plans/2026-04-28-runtime-services-layout-audit.md new file mode 100644 index 00000000..c4a2430e --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-runtime-services-layout-audit.md @@ -0,0 +1,527 @@ +# Runtime Services Layout Audit + +Date: 2026-04-28 + +Scope: `ergon_core/ergon_core/core/runtime/services` in the `core-schema-dedup` worktree. + +This note is an investigation artifact for a later fix/refactor plan. It does not propose a final migration sequence yet. The goal is to identify where `runtime/services` has become a dumping ground, where service shapes are inconsistent, and where logic appears duplicated or split across weak domain boundaries. + +## Executive Summary + +`runtime/services` is doing too many jobs in one flat namespace: + +- Domain orchestration services (`TaskExecutionService`, `WorkflowInitializationService`, `WorkflowFinalizationService`). +- Graph mutation and graph read helpers (`WorkflowGraphRepository`, `GraphNodeLookup`, graph DTOs). +- Agent/tool-facing subtask services (`TaskManagementService`, `TaskInspectionService`). +- API/dashboard read models (`RunReadService`, `WorkflowService`). +- Persistence helpers (`ExperimentPersistenceService`, `EvaluationPersistenceService`). +- Product areas that are not obviously part of runtime orchestration (`CommunicationService`, cohort services). +- Transport contracts for Inngest and API surfaces (`*_dto.py`, `*_schemas.py`, `child_function_payloads.py`, `inngest_function_results.py`). + +The resulting issue is not just file count. The same concepts are implemented with different local conventions: request/response models may be named DTOs, schemas, payloads, or function results; DB access may use explicit sessions, `with get_session()`, or ad hoc repository instances; graph traversal and latest-execution lookup logic are repeated with inconsistent ordering rules. + +## Current File Groups + +### Graph And Graph Mutation + +- `graph_repository.py` +- `graph_lookup.py` +- `graph_dto.py` +- `task_management_service.py` +- `task_inspection_service.py` +- `task_management_dto.py` +- `task_inspection_dto.py` +- `subtask_cancellation_service.py` +- `subtask_cancellation_dto.py` +- `subtask_blocking_service.py` + +This is the densest cluster. It covers graph mutation, graph traversal, task/subtask management, inspection, cancellation, blocking, and graph DTOs. + +### Workflow And Run Lifecycle + +- `run_service.py` +- `workflow_initialization_service.py` +- `workflow_finalization_service.py` +- `workflow_service.py` +- `workflow_dto.py` +- `orchestration_dto.py` + +This group mixes run lifecycle orchestration with workflow navigation/resource materialization. `workflow_service.py` is read-heavy and tool/API-facing, while `workflow_initialization_service.py` and `workflow_finalization_service.py` are engine lifecycle services. + +### Task Execution And Propagation + +- `task_execution_service.py` +- `task_propagation_service.py` +- `task_cleanup_service.py` +- `task_cleanup_dto.py` + +This group owns execution row creation/finalization, graph status updates for task execution, propagation after completion/failure, and cleanup of cancelled task executions. + +### Evaluation + +- `rubric_evaluation_service.py` +- `evaluator_dispatch_service.py` +- `evaluation_persistence_service.py` +- `evaluation_dto.py` + +This group mixes evaluator preparation, rubric execution, persistence, and dashboard DTO shaping. + +### API Read Models And Product Features + +- `run_read_service.py` +- `communication_service.py` +- `communication_schemas.py` +- `cohort_service.py` +- `cohort_stats_service.py` +- `cohort_schemas.py` + +These are valid application services, but they are not the same kind of service as runtime orchestration. Their presence in the same flat package makes ownership harder to read. + +### Transport Contracts + +- `child_function_payloads.py` +- `inngest_function_results.py` +- plus the various `*_dto.py` and `*_schemas.py` files + +These are request/response contracts, not services. They currently sit beside service implementations without a consistent folder or naming convention. + +## Standardization Gaps + +### No Common Service Module Shape + +The desired structure is roughly: + +- request/response models +- DB schema types +- `repository.py` or service implementation +- `errors.py` for custom domain/service exceptions +- optional `utils.py` + +The current structure is flat and inconsistent: + +- Some service request/response models live in `*_dto.py`. +- Some live in `*_schemas.py`. +- Inngest request models live in `child_function_payloads.py`. +- Inngest outputs live in `inngest_function_results.py`. +- Some service-specific helper models live in the same service file. +- Persistence-facing repositories live partly in `core/persistence` and partly in `runtime/services`. +- Custom exceptions live mostly in broad runtime error modules, not beside the service/domain that raises them. + +This makes it difficult to infer whether a file is a domain service, transport contract, read model, or persistence adapter. + +### Error Types Are Not Domain-Local + +Some custom errors already exist under `core/runtime/errors`, for example graph, delegation, and Inngest-specific error modules. That is better than raising generic `ValueError` everywhere, but it still leaves service packages without local ownership of their failure modes. + +The target convention should be: each runtime domain package owns an `errors.py` file for exceptions that are part of that domain contract. For example: + +- `runtime/graph/errors.py` for graph structural and mutation errors. +- `runtime/tasks/errors.py` for task execution, task management, cleanup, cancellation, and inspection failures. +- `runtime/workflows/errors.py` for workflow initialization/finalization/lifecycle failures. +- `runtime/evaluation/errors.py` for evaluator dispatch, rubric evaluation, and evaluation persistence failures. +- `runtime/inngest/errors.py` for Inngest wrapper/contract/non-retryable errors. + +This does not mean every exception class needs to move immediately. The refactor plan should move errors opportunistically with the package they belong to, and should prefer explicit custom exceptions over generic `ValueError`, `RuntimeError`, or assertion-style checks at service boundaries. + +### Repository Naming Is Ambiguous + +`WorkflowGraphRepository` is in `runtime/services/graph_repository.py`, while persistence repositories live in: + +- `core/persistence/context/repository.py` +- `core/persistence/telemetry/repositories.py` + +This is understandable because `WorkflowGraphRepository` owns runtime graph mutation semantics and audit-log writes, not just raw CRUD. Still, the package shape blurs whether repositories are persistence infrastructure or runtime domain services. + +### Session Ownership Varies + +Patterns include: + +- Methods accepting an explicit `Session`. +- Services opening `with get_session() as session`. +- Services using `session = get_session()` with manual `finally: session.close()`. +- Repository classes receiving a session from callers. + +Examples: + +- `TaskManagementService`, `SubtaskCancellationService`, and `WorkflowService` accept caller-owned sessions. +- `RunReadService`, `RunService`, `WorkflowInitializationService`, and `WorkflowFinalizationService` open sessions internally. +- `EvaluationPersistenceService` manually opens and closes sessions instead of using `with get_session()`. + +This makes transaction boundaries harder to reason about and complicates any future service package convention. + +## Concrete Duplication Findings + +### P1: Duplicate Latest Execution Lookup + +Two files define the same helper: + +- `task_management_service.py` +- `subtask_cancellation_service.py` + +Both query `RunTaskExecution.id` by `node_id`, ordered by `RunTaskExecution.started_at.desc()`, and use it to populate `TaskCancelledEvent.execution_id`. + +Related methods in other services define "latest execution" differently: + +- `WorkflowService.get_latest_execution` orders by `attempt_number DESC`, then `started_at DESC`. +- `TaskInspectionService._latest_output` and `_latest_error` order only by `started_at DESC`. + +This is a real semantic duplication. There should be one canonical helper for "latest execution for node", with a clearly documented ordering rule. + +### P1: Duplicate Containment Subtree Traversal + +The same parent-child BFS pattern appears in: + +- `task_management_service.py` via `_count_non_terminal_descendants`. +- `subtask_cancellation_service.py` via `cancel_orphans`. +- `subtask_blocking_service.py` via `block_pending_descendants`. + +All query `RunGraphNode` children by `run_id` and `parent_node_id`, then apply a different policy: + +- Count non-terminal descendants. +- Cancel non-terminal descendants. +- Block non-terminal, non-running descendants. + +This should become a shared graph traversal primitive, with the policy supplied by the caller or by domain-specific cascade services. + +### P1: Scattered Graph Status Transitions + +Graph node and edge status writes appear across: + +- `task_execution_service.py` +- `task_propagation_service.py` +- `task_management_service.py` +- `subtask_cancellation_service.py` +- `subtask_blocking_service.py` +- `workflow_initialization_service.py` +- `graph_repository.py` + +`WorkflowGraphRepository` intentionally does not validate transitions; it only records mutations and enforces structural invariants. That boundary is reasonable, but the transition policy above it is distributed across many services. + +The refactor plan should decide whether there is a single graph lifecycle domain service, or at least a small set of named transition operations such as: + +- start node execution +- complete node execution +- fail node execution +- reset node for restart +- cancel subtree +- block subtree +- satisfy dependency edge + +### P2: Duplicated Graph Mapping / Read Loading + +`GraphNodeLookup` batch-loads mappings from definition task IDs and edges to run graph IDs. + +`RunReadService.build_run_snapshot` builds similar maps inline: + +- `execution_task_map` +- `defn_to_node` +- task maps and context-event maps through API helper functions + +`WorkflowService` also builds node maps through `_nodes_by_id` and tree/resource scopes through local queries. + +These are not identical consumers, but the primitives overlap: load run graph, map definition IDs to node IDs, map executions to nodes, and traverse parent/child relationships. + +### P2: Evaluation Score Semantics Drift + +`WorkflowFinalizationService` computes: + +- `final_score = sum(scores)` +- `normalized_score = final_score / len(scores)` + +`RunReadService.build_run_snapshot` computes: + +- `final_score = sum(scores) / len(scores)` + +`TelemetryRepository.refresh_run_evaluation_summary` also updates summary fields from evaluation rows. + +`cohort_service.py` and `cohort_stats_service.py` then read `normalized_score` and `final_score` from summary JSON. This should be centralized because downstream consumers depend on the meaning of these fields. + +### P2: Read Model Shaping Depends On API Helpers + +`RunReadService` imports DTOs from `ergon_core.core.api.schemas` and imports `ergon_core.core.api.runs` helper functions inside `build_run_snapshot`. + +That means a runtime service depends upward on API helpers. This is likely a layering smell. The pure DTO helper functions should either move into a runtime/read-model package, or the API should own the service and not call it "runtime". + +### P3: Repeated Graph Repository Construction + +`WorkflowGraphRepository()` is constructed in many places: + +- `task_execution_service.py` +- `task_propagation_service.py` +- `workflow_initialization_service.py` +- `task_management_service.py` +- `subtask_cancellation_service.py` +- `subtask_blocking_service.py` + +The repository is mostly stateless, but it has mutation listeners. `TaskManagementService` registers `dashboard_emitter.graph_mutation`; other construction sites do not. If listeners are meant to be consistently applied, construction should be standardized. If not, the listener behavior should be explicit at call sites or separated from repository construction. + +### P3: DTO Naming And Boundaries Are Mixed + +Current naming patterns include: + +- `graph_dto.py` +- `workflow_dto.py` +- `task_management_dto.py` +- `task_inspection_dto.py` +- `evaluation_dto.py` +- `cohort_schemas.py` +- `communication_schemas.py` +- `child_function_payloads.py` +- `inngest_function_results.py` + +The differences may have history, but they do not communicate ownership. A student/user reading the package cannot easily tell whether "schema", "DTO", "payload", and "result" are meaningful distinctions. + +### P3: Task Reference Shapes Overlap + +The following are related but split: + +- `GraphTaskRef` in `graph_dto.py` +- `TaskDescriptor` in `orchestration_dto.py` +- `SubtaskInfo` in `task_inspection_dto.py` +- `WorkflowDependencyRef.source` / `target` in `workflow_dto.py` +- `AddSubtaskResult`, `CancelTaskResult`, and `RestartTaskResult` in `task_management_dto.py` + +Some separation is legitimate, but the shared task identity payload should be explicit. The current split risks reintroducing separate names/status fields for the same runtime graph node. + +## Boundary Assessment + +### Things That Belong Near Persistence + +These are schema or data-access concerns: + +- SQLModel table definitions in `core/persistence`. +- Shared DB session creation in `core/persistence/shared/db.py`. +- Shared persisted enums and types in `core/persistence/shared`. +- Context and telemetry repositories that are mostly append/read/write around specific persisted rows. +- Definition persistence may be a better fit near `core/persistence/definitions` than under `runtime/services`. + +Candidate to move or reframe: + +- `experiment_persistence_service.py` + +It writes immutable experiment definition tables. It is not obviously a runtime orchestration service. + +### Things That Belong In Runtime Domain Packages + +These are runtime domain behavior, not raw persistence: + +- Graph mutation repository and mutation DTOs. +- Task execution lifecycle. +- Propagation and graph lifecycle transitions. +- Agent/tool-facing task management and inspection. +- Inngest command/result contracts. + +Candidate runtime packages: + +- `runtime/graph` +- `runtime/tasks` +- `runtime/workflows` +- `runtime/evaluation` +- `runtime/read_models` +- `runtime/inngest/contracts` + +The exact package names can wait for the refactor plan, but the target should be domain packages rather than one `services` bucket. + +### Things Inngest Should Own + +The Inngest function implementations already live under `core/runtime/inngest`, but two Inngest-owned modules currently sit at the top of `core/runtime`: + +- `inngest_client.py` +- `inngest_registry.py` + +These should move under `runtime/inngest` with the function modules. The Inngest package should own: + +- the client singleton and shared cancellation configuration +- the function registry / function list passed to `serve()` +- function modules +- child-function request contracts and function result contracts, unless those contracts are better colocated with the specific function module +- Inngest-specific errors + +This would make `runtime/inngest` the runtime boundary for event orchestration instead of spreading its setup across `runtime` and `runtime/services`. + +### Things That Are Product/Application Services + +These may belong outside the runtime kernel, or in separate runtime subdomains: + +- `communication_service.py` +- `cohort_service.py` +- `cohort_stats_service.py` +- `run_read_service.py` + +They are valid application concerns, but colocating them with graph mutation and task execution weakens the meaning of `services`. + +## Suggested Target Shape + +This is a sketch, not a final implementation plan. + +```text +core/runtime/ + graph/ + models.py # runtime DTOs for graph snapshots and mutation records + repository.py # WorkflowGraphRepository + errors.py # graph structural and mutation errors + traversal.py # subtree and dependency traversal primitives + lookup.py # GraphNodeLookup or successor + lifecycle.py # named graph status transitions, if introduced + + tasks/ + models.py # task execution commands/results, task refs + errors.py # task execution/management/cancellation errors + execution.py # TaskExecutionService + management.py # agent-initiated subtask operations + inspection.py # read-only subtask snapshots + cleanup.py # per-execution cleanup + cascades.py # cancellation/blocking/downstream invalidation + + workflows/ + models.py # workflow lifecycle commands/results + errors.py + initialization.py + finalization.py + service.py # workflow navigation/resource materialization, if kept here + + evaluation/ + models.py + errors.py + dispatch.py + rubric.py + persistence.py + scoring.py # shared score aggregation semantics + + read_models/ + errors.py + run_snapshot.py # RunReadService and pure DTO shaping helpers + + inngest/ + client.py # Inngest singleton and cancellation config + registry.py # ALL_FUNCTIONS / serve() function list + contracts.py # child payloads and function results, or per-event modules + errors.py # Inngest/non-retryable/contract wrapper errors + functions/ # optional if we want one subdirectory below package root +``` + +The key convention is that each domain package should make its file roles obvious: + +- `models.py` for request/response/domain DTOs. +- `repository.py` only where the module owns persisted mutation/read-write behavior. +- `errors.py` for exceptions that are part of that service/domain contract. +- `service.py` or named service files for use-case orchestration. +- `utils.py` or more specific helper modules only for reusable pure helpers. + +For Inngest specifically, avoid a separate top-level `runtime/inngest_client.py` or `runtime/inngest_registry.py`; the `runtime/inngest` package should own those pieces directly. + +## High-Value Refactor Candidates + +### 1. Extract Graph Traversal Primitives + +Create a small module for containment traversal by `parent_node_id`. + +Initial consumers: + +- `task_management_service._count_non_terminal_descendants` +- `subtask_cancellation_service.cancel_orphans` +- `subtask_blocking_service.block_pending_descendants` +- `workflow_service._descendant_ids` + +This is the clearest low-risk cleanup because the duplicated query shape is visible and bounded. + +### 2. Centralize Latest Execution Selection + +Create one helper or repository method for "latest execution for node". + +It should define ordering once, probably: + +1. `attempt_number DESC` +2. `started_at DESC` + +Then update: + +- `WorkflowService.get_latest_execution` +- `TaskInspectionService._latest_output` +- `TaskInspectionService._latest_error` +- `task_management_service._latest_execution_id` +- `subtask_cancellation_service._latest_execution_id` + +### 3. Centralize Evaluation Score Aggregation + +Create one score aggregation helper that returns a named object: + +- `final_score` +- `normalized_score` +- `evaluators_count` + +Then update: + +- `WorkflowFinalizationService` +- `TelemetryRepository.refresh_run_evaluation_summary` +- `RunReadService.build_run_snapshot` +- cohort summary readers if their semantics need adjustment + +### 4. Split DTO/Schema Contracts From Service Implementations + +Normalize naming inside any new package: + +- Use `models.py` for request/response DTOs within runtime domain packages. +- Reserve `schemas.py` for API wire schemas only if the codebase keeps that distinction. +- Avoid mixing Inngest contracts with service DTOs unless the package name makes that explicit. + +### 5. Move API Snapshot Helpers Out Of API Layer + +`RunReadService` should not need to import `ergon_core.core.api.runs` helper functions. Move pure task/resource/evaluation snapshot builders to a runtime read-model module, or move `RunReadService` behind the API layer. + +### 6. Decide Whether `WorkflowGraphRepository` Is A Repository Or Domain Service + +Two defensible options: + +- Keep it in runtime, but move it to `runtime/graph/repository.py` and make clear that it is a domain repository for graph mutations, not a generic persistence repository. +- Move it nearer `persistence/graph`, but prevent it from depending on runtime dashboard/event DTOs. + +The first option probably fits the current design better because the repository writes audit mutations and encodes structural invariants, not just SQL CRUD. + +### 7. Move Inngest Ownership Into The Inngest Package + +Move or plan to move: + +- `runtime/inngest_client.py` to `runtime/inngest/client.py` +- `runtime/inngest_registry.py` to `runtime/inngest/registry.py` +- `services/child_function_payloads.py` to `runtime/inngest/contracts.py` or per-function contract modules +- `services/inngest_function_results.py` to `runtime/inngest/contracts.py` or per-function result modules +- `runtime/errors/inngest_errors.py` to `runtime/inngest/errors.py` + +This should be mostly import churn, but the plan should include architecture tests so Inngest setup does not drift back into `runtime/services`. + +### 8. Add Domain-Local Error Modules + +As packages are split, add `errors.py` to each domain package. The first pass can be mechanical: + +- graph errors follow `WorkflowGraphRepository` +- delegation/task errors follow task management and inspection +- Inngest errors follow the Inngest client and functions +- evaluation-specific contract violations move with evaluation services if they are not broadly runtime-level + +The plan should not require inventing custom errors for every possible branch in one pass. It should require that new service boundary failures use domain-specific exception types, and that moved services do not keep reaching into a shared dumping-ground error module when a local `errors.py` is clearer. + +## Questions For The Refactor Plan + +1. Should `services` disappear entirely in favor of domain packages, or should it remain as a compatibility import layer during migration? +2. Should request/response models live in `models.py` beside each domain package, or in separate `contracts.py` files when they are consumed by Inngest/API boundaries? +3. Should `WorkflowGraphRepository` emit/listen to dashboard mutations directly, or should dashboard emission sit above the repository? +4. Should read-model services be considered runtime services, API services, or their own `runtime/read_models` layer? +5. Should definition persistence move under `persistence/definitions`, or stay in runtime because it converts authored experiments into persisted definition rows? +6. Should each package expose its domain errors from `__init__.py`, or should callers import directly from `package.errors` to avoid new barrel behavior? +7. Should Inngest contracts be centralized in one `runtime/inngest/contracts.py`, or colocated with each function module? + +## Recommended Next Step + +Write a refactor plan that starts with mechanical, low-risk extractions before package moves: + +1. Extract shared latest-execution helper. +2. Extract graph traversal helper. +3. Extract evaluation score aggregation helper. +4. Move pure run snapshot helper functions out of `core.api.runs`. +5. Move Inngest client, registry, contracts, results, and errors under `runtime/inngest`. +6. Introduce domain package structure with one package at a time, starting with `runtime/graph`. +7. Add `errors.py` to each package as services move, and replace generic service-boundary exceptions where the domain already has a clear failure type. +8. Move/rename services only after tests prove the helpers preserve behavior. + +This order reduces risk because it fixes semantic duplication before large import churn. From 164b17df64c2652c3ebff83e8dbbbfb5f72166ff Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:38:02 +0100 Subject: [PATCH 34/66] Stream generation parts through context events Preserve structured generation parts as context event chunks so downstream adapters and workers share a single transcript assembly path. Made-with: Cursor --- .../common/llm_context/adapters/base.py | 6 +- .../llm_context/adapters/pydantic_ai.py | 207 +++++++--------- .../workers/baselines/react_worker.py | 62 ++--- .../workers/baselines/training_stub_worker.py | 79 +++---- .../research_rubrics/researcher_worker.py | 8 +- .../workflow_cli_react_worker.py | 8 +- ergon_core/ergon_core/api/results.py | 4 +- ergon_core/ergon_core/api/worker.py | 26 +- ergon_core/ergon_core/core/generation.py | 79 ++++--- .../persistence/context/event_payloads.py | 133 +---------- .../core/persistence/context/models.py | 6 +- .../core/persistence/context/repository.py | 223 +++++------------- ergon_core/ergon_core/core/rl/extraction.py | 98 +++----- .../core/runtime/inngest/worker_execute.py | 44 ++-- ergon_core/ergon_core/core/runtime/tracing.py | 4 +- .../smoke_fixtures/smoke_base/leaf_base.py | 36 ++- .../smoke_fixtures/smoke_base/recursive.py | 42 ++-- .../smoke_fixtures/smoke_base/worker_base.py | 52 ++-- tests/e2e/_asserts.py | 7 +- .../architecture/test_core_schema_sources.py | 25 ++ .../common/test_transcript_adapters.py | 143 +++++------ .../test_context_event_repository.py | 108 ++++++--- .../runtime/test_context_event_contracts.py | 9 +- tests/unit/state/test_context_assembly.py | 144 ++++------- tests/unit/state/test_context_part_stream.py | 64 +++++ .../unit/state/test_generation_turn_build.py | 83 ------- .../state/test_research_rubrics_workers.py | 4 +- .../workers/test_react_worker_contract.py | 16 +- 28 files changed, 692 insertions(+), 1028 deletions(-) create mode 100644 tests/unit/state/test_context_part_stream.py delete mode 100644 tests/unit/state/test_generation_turn_build.py diff --git a/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py b/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py index 2cb7f599..cbec5880 100644 --- a/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py +++ b/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py @@ -2,7 +2,7 @@ from typing import Protocol, TypeVar -from ergon_core.core.generation import GenerationTurn +from ergon_core.core.generation import ContextPartChunk from ergon_core.core.persistence.context.models import RunContextEvent TranscriptT = TypeVar("TranscriptT") @@ -12,8 +12,8 @@ class TranscriptAdapter(Protocol[TranscriptT, ReplayT]): """Convert between framework-native transcripts and Ergon context events.""" - def build_turns(self, transcript: TranscriptT) -> list[GenerationTurn]: - """Return ordered turns extracted from a complete transcript.""" + def build_chunks(self, transcript: TranscriptT) -> list[ContextPartChunk]: + """Return ordered chunks extracted from a complete transcript.""" ... def assemble_replay(self, events: list[RunContextEvent]) -> ReplayT: diff --git a/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py b/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py index 6fb6b554..250d1a1e 100644 --- a/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py +++ b/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py @@ -3,24 +3,15 @@ import json from ergon_core.core.generation import ( - GenerationTurn, - ModelRequestPart as ErgonModelRequestPart, - ModelResponsePart as ErgonModelResponsePart, + AssistantTextPart, + ContextPartChunk, + ContextPartChunkLog, SystemPromptPart, - TextPart, ThinkingPart, TokenLogprob, ToolCallPart, - ToolReturnPart, - UserPromptPart, -) -from ergon_core.core.persistence.context.event_payloads import ( - AssistantTextPayload, - SystemPromptPayload, - ThinkingPayload, - ToolCallPayload, - ToolResultPayload, - UserMessagePayload, + ToolResultPart, + UserMessagePart, ) from ergon_core.core.persistence.context.models import RunContextEvent from pydantic import BaseModel @@ -39,37 +30,37 @@ class TranscriptTurnCursor(BaseModel): - """Track how many turns have already been emitted from a growing transcript.""" + """Track how many chunks have already been emitted from a growing transcript.""" model_config = {"validate_assignment": True} - emitted_turn_count: int = 0 + emitted_chunk_count: int = 0 class PydanticAITranscriptAdapter(TranscriptAdapter[list[ModelMessage], list[ModelMessage]]): - """Convert complete PydanticAI message histories into Ergon turns.""" + """Convert PydanticAI message histories into Ergon context stream chunks.""" - def build_turns( + def build_chunks( self, transcript: list[ModelMessage], *, flush_pending: bool = True, - ) -> list[GenerationTurn]: - """Build turns from a complete PydanticAI message list.""" - return _build_turns_from_transcript(transcript, flush_pending=flush_pending) + ) -> list[ContextPartChunk]: + """Build context stream chunks from a complete PydanticAI message list.""" + return _build_chunks_from_transcript(transcript, flush_pending=flush_pending) - def build_new_turns( + def build_new_chunks( self, transcript: list[ModelMessage], cursor: TranscriptTurnCursor, *, flush_pending: bool = False, - ) -> list[GenerationTurn]: - """Return turns not previously emitted for a growing transcript.""" - turns = _build_turns_from_transcript(transcript, flush_pending=flush_pending) - new_turns = turns[cursor.emitted_turn_count :] - cursor.emitted_turn_count = len(turns) - return new_turns + ) -> list[ContextPartChunk]: + """Return chunks not previously emitted for a growing transcript.""" + chunks = _build_chunks_from_transcript(transcript, flush_pending=flush_pending) + new_chunks = chunks[cursor.emitted_chunk_count :] + cursor.emitted_chunk_count = len(chunks) + return new_chunks def assemble_replay(self, events: list[RunContextEvent]) -> list[ModelMessage]: """Reconstruct PydanticAI messages from ordered context events.""" @@ -80,7 +71,7 @@ def assemble_replay(self, events: list[RunContextEvent]) -> list[ModelMessage]: for event in events: payload = event.parsed_payload() if request_part := _to_pydantic_request_part(payload): - if isinstance(payload, ToolResultPayload) and current_response_parts: + if isinstance(payload.part, ToolResultPart) and current_response_parts: messages.append(ModelResponse(parts=current_response_parts)) current_response_parts = [] current_request_parts.append(request_part) @@ -96,48 +87,27 @@ def assemble_replay(self, events: list[RunContextEvent]) -> list[ModelMessage]: return messages -def _build_turns_from_transcript( +def _build_chunks_from_transcript( transcript: list[ModelMessage], *, flush_pending: bool, -) -> list[GenerationTurn]: - turns: list[GenerationTurn] = [] +) -> list[ContextPartChunk]: + chunks: list[ContextPartChunk] = [] pending_response: ModelResponse | None = None - pending_request_in: ModelRequest | None = None for message in transcript: if isinstance(message, ModelRequest): if pending_response is not None: - turns.append( - _to_turn( - pending_request_in, - pending_response, - tool_result_request=message, - ) - ) + chunks.extend(_chunks_from_response(pending_response)) pending_response = None - pending_request_in = None - pending_request_in = message + chunks.extend(_chunks_from_request(message)) elif isinstance(message, ModelResponse): pending_response = message if pending_response is not None and flush_pending: - turns.append(_to_turn(pending_request_in, pending_response, tool_result_request=None)) - - return turns + chunks.extend(_chunks_from_response(pending_response)) - -def _to_turn( - request_in: ModelRequest | None, - response: ModelResponse, - tool_result_request: ModelRequest | None, -) -> GenerationTurn: - return GenerationTurn( - messages_in=_extract_request_parts(request_in) if request_in else [], - response_parts=_extract_response_parts(response), - tool_results=_extract_tool_results(tool_result_request) if tool_result_request else [], - turn_logprobs=extract_logprobs(response), - ) + return chunks def extract_logprobs(response: ModelResponse) -> list[TokenLogprob] | None: @@ -170,91 +140,90 @@ def extract_logprobs(response: ModelResponse) -> list[TokenLogprob] | None: return logprobs or None -def _extract_request_parts(request: ModelRequest) -> list[ErgonModelRequestPart]: - parts: list[ErgonModelRequestPart] = [] +def _serialize_tool_content(content: ToolReturnContent) -> str: + if isinstance(content, str): + return content + return json.dumps(to_jsonable_python(content)) + + +def _chunks_from_request(request: ModelRequest) -> list[ContextPartChunk]: + chunks: list[ContextPartChunk] = [] for part in request.parts: if isinstance(part, PydanticSystemPromptPart): - parts.append(SystemPromptPart(content=part.content)) + chunks.append(ContextPartChunk(part=SystemPromptPart(content=part.content))) elif isinstance(part, PydanticUserPromptPart) and isinstance(part.content, str): - parts.append(UserPromptPart(content=part.content)) - return parts + chunks.append(ContextPartChunk(part=UserMessagePart(content=part.content))) + elif isinstance(part, PydanticToolReturnPart): + chunks.append( + ContextPartChunk( + part=ToolResultPart( + tool_call_id=part.tool_call_id, + tool_name=part.tool_name, + content=_serialize_tool_content(part.content), + ) + ) + ) + return chunks -def _extract_response_parts(response: ModelResponse) -> list[ErgonModelResponsePart]: - parts: list[ErgonModelResponsePart] = [] +def _chunks_from_response(response: ModelResponse) -> list[ContextPartChunk]: + logprobs = extract_logprobs(response) + chunks: list[ContextPartChunk] = [] for part in response.parts: if isinstance(part, PydanticTextPart): - parts.append(TextPart(content=part.content)) + chunks.append( + ContextPartChunk(part=AssistantTextPart(content=part.content), logprobs=logprobs) + ) + logprobs = None elif isinstance(part, PydanticToolCallPart): - parts.append( - ToolCallPart( - tool_name=part.tool_name, - tool_call_id=part.tool_call_id, - args=part.args_as_dict(), + chunks.append( + ContextPartChunk( + part=ToolCallPart( + tool_name=part.tool_name, + tool_call_id=part.tool_call_id, + args=part.args_as_dict(), + ), + logprobs=logprobs, ) ) + logprobs = None elif isinstance(part, PydanticThinkingPart): - parts.append(ThinkingPart(content=part.content)) - return parts - - -def _extract_tool_results(request: ModelRequest) -> list[ToolReturnPart]: - results: list[ToolReturnPart] = [] - for part in request.parts: - if isinstance(part, PydanticToolReturnPart): - results.append( - ToolReturnPart( - tool_call_id=part.tool_call_id, - tool_name=part.tool_name, - content=_serialize_tool_content(part.content), - ) + chunks.append( + ContextPartChunk(part=ThinkingPart(content=part.content), logprobs=logprobs) ) - return results - - -def _serialize_tool_content(content: ToolReturnContent) -> str: - if isinstance(content, str): - return content - return json.dumps(to_jsonable_python(content)) + logprobs = None + return chunks def _to_pydantic_response_part( - payload: AssistantTextPayload - | ThinkingPayload - | ToolCallPayload - | SystemPromptPayload - | UserMessagePayload - | ToolResultPayload, + payload: ContextPartChunkLog, ) -> PydanticModelResponsePart | None: - if isinstance(payload, ThinkingPayload): - return PydanticThinkingPart(content=payload.text) - if isinstance(payload, AssistantTextPayload): - return PydanticTextPart(content=payload.text) - if isinstance(payload, ToolCallPayload): + part = payload.part + if isinstance(part, ThinkingPart): + return PydanticThinkingPart(content=part.content) + if isinstance(part, AssistantTextPart): + return PydanticTextPart(content=part.content) + if isinstance(part, ToolCallPart): return PydanticToolCallPart( - tool_name=payload.tool_name, - tool_call_id=payload.tool_call_id, - args=payload.args, + tool_name=part.tool_name, + tool_call_id=part.tool_call_id, + args=part.args, ) return None def _to_pydantic_request_part( - payload: AssistantTextPayload - | ThinkingPayload - | ToolCallPayload - | SystemPromptPayload - | UserMessagePayload - | ToolResultPayload, + payload: ContextPartChunkLog, ) -> PydanticModelRequestPart | None: - if isinstance(payload, SystemPromptPayload): - return PydanticSystemPromptPart(content=payload.text) - if isinstance(payload, UserMessagePayload): - return PydanticUserPromptPart(content=payload.text) - if isinstance(payload, ToolResultPayload): + part = payload.part + if isinstance(part, SystemPromptPart): + return PydanticSystemPromptPart(content=part.content) + if isinstance(part, UserMessagePart): + return PydanticUserPromptPart(content=part.content) + if isinstance(part, ToolResultPart): return PydanticToolReturnPart( - tool_call_id=payload.tool_call_id, - tool_name=payload.tool_name, - content=str(payload.result), + tool_call_id=part.tool_call_id, + tool_name=part.tool_name, + content=part.content, ) return None diff --git a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py index 752438ec..b739b224 100644 --- a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py @@ -8,7 +8,12 @@ from uuid import UUID from ergon_core.api import BenchmarkTask, Worker, WorkerContext, WorkerOutput -from ergon_core.core.generation import GenerationTurn +from ergon_core.core.generation import ( + AssistantTextPart, + ContextPartChunk, + ThinkingPart, + ToolCallPart, +) from ergon_core.core.persistence.context.repository import ContextEventRepository from ergon_core.core.persistence.shared.db import get_session from pydantic import BaseModel @@ -36,8 +41,8 @@ class _AgentOutput(BaseModel): class ReActWorker(Worker): """ReAct-style worker that delegates to a pydantic-ai Agent. - Yields ``GenerationTurn`` objects after the run completes. Each - yielded turn is persisted to PG by the runtime. + Yields ``ContextPartChunk`` objects as the PydanticAI transcript grows. Each + yielded chunk is enriched and persisted by the runtime. All wiring (tool list, system prompt, iteration budget) is supplied at construction time — the worker is framework-agnostic. Registry @@ -69,9 +74,9 @@ async def execute( task: BenchmarkTask, *, context: WorkerContext, - ) -> AsyncGenerator[GenerationTurn, None]: - async for turn in self._run_agent(task, context): - yield turn + ) -> AsyncGenerator[ContextPartChunk, None]: + async for chunk in self._run_agent(task, context): + yield chunk def build_agent_deps( self, context: WorkerContext @@ -82,8 +87,8 @@ async def _run_agent( self, task: BenchmarkTask, context: WorkerContext, - ) -> AsyncGenerator[GenerationTurn, None]: - """Run the underlying pydantic-ai agent and yield the turns it produced.""" + ) -> AsyncGenerator[ContextPartChunk, None]: + """Run the underlying pydantic-ai agent and yield the chunks it produced.""" resolved = resolve_model_target(self.model) configure_pydantic_ai_logfire() agent_deps = self.build_agent_deps(context) @@ -113,43 +118,43 @@ async def _run_agent( run = active_run async for _node in run: node_count += 1 - for turn in adapter.build_new_turns( + for chunk in adapter.build_new_chunks( run.ctx.state.message_history, cursor, flush_pending=False, ): - yield turn + yield chunk if node_count >= self.max_iterations: logger.warning( "ReActWorker hit max_iterations=%d; persisting partial turns", self.max_iterations, ) - for turn in adapter.build_new_turns( + for chunk in adapter.build_new_chunks( run.ctx.state.message_history, cursor, flush_pending=True, ): - yield turn + yield chunk raise RuntimeError( f"ReActWorker exceeded max_iterations={self.max_iterations}" ) except Exception: # slopcop: ignore[no-broad-except] if run is not None: - for turn in adapter.build_new_turns( + for chunk in adapter.build_new_chunks( run.ctx.state.message_history, cursor, flush_pending=True, ): - yield turn + yield chunk raise if run is not None: - for turn in adapter.build_new_turns( + for chunk in adapter.build_new_chunks( run.ctx.state.message_history, cursor, flush_pending=True, ): - yield turn + yield chunk def get_output(self, context: WorkerContext) -> WorkerOutput: """Extract the agent's text output from the last context event.""" @@ -157,13 +162,6 @@ def get_output(self, context: WorkerContext) -> WorkerOutput: def _base_output(self, context: WorkerContext) -> WorkerOutput: """Build the worker's output from persisted context events.""" - # reason: avoid circular import at module level - from ergon_core.core.persistence.context.event_payloads import ( - AssistantTextPayload, - ThinkingPayload, - ToolCallPayload, - ) - with get_session() as session: repo = ContextEventRepository() events = repo.get_for_execution(session, context.execution_id) @@ -171,12 +169,14 @@ def _base_output(self, context: WorkerContext) -> WorkerOutput: turn_ids: set[str] = set() for e in events: payload = e.parsed_payload() - if isinstance(payload, (AssistantTextPayload, ToolCallPayload, ThinkingPayload)): + if isinstance(payload.part, (AssistantTextPart, ToolCallPart, ThinkingPart)): + if payload.turn_id is None: + continue turn_ids.add(payload.turn_id) text_events = [e for e in events if e.event_type == "assistant_text"] if not text_events: - output = _latest_final_result_message(events, ToolCallPayload) + output = _latest_final_result_message(events) if not output: return WorkerOutput(output="", success=False) return WorkerOutput( @@ -185,10 +185,10 @@ def _base_output(self, context: WorkerContext) -> WorkerOutput: metadata={"turn_count": len(turn_ids)}, ) last = text_events[-1].parsed_payload() - if not isinstance(last, AssistantTextPayload): - raise ValueError(f"Expected AssistantTextPayload, got {type(last)}") + if not isinstance(last.part, AssistantTextPart): + raise ValueError(f"Expected AssistantTextPart, got {type(last.part)}") return WorkerOutput( - output=last.text, + output=last.part.content, success=True, metadata={"turn_count": len(turn_ids)}, ) @@ -221,7 +221,6 @@ def _format_task(task: BenchmarkTask) -> str: def _latest_final_result_message( events: list[Any], # slopcop: ignore[no-typing-any] - payload_type: type[Any], # slopcop: ignore[no-typing-any] ) -> str: """Extract fallback text from the latest ``final_result`` tool call.""" messages: list[str] = [] @@ -233,7 +232,8 @@ def _latest_final_result_message( if event_type != "tool_call": continue payload = event.parsed_payload() - if not isinstance(payload, payload_type) or payload.tool_name != "final_result": + part = payload.part + if not isinstance(part, ToolCallPart) or part.tool_name != "final_result": continue - messages.append(str(payload.args.get("final_assistant_message", ""))) + messages.append(str(part.args.get("final_assistant_message", ""))) return messages[-1] if messages else "" diff --git a/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py b/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py index 0f41657d..44e21b14 100644 --- a/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py +++ b/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py @@ -1,4 +1,4 @@ -"""Stub worker that produces synthetic GenerationTurn data for RL testing. +"""Stub worker that produces synthetic context chunk data for RL testing. Unlike stub-worker (which returns a plain string with no turns), this worker generates fake token-level data that exercises the full trajectory @@ -11,20 +11,16 @@ import random from collections.abc import AsyncGenerator -from typing import cast from uuid import UUID from ergon_core.api import BenchmarkTask, Worker, WorkerContext from ergon_core.core.generation import ( - GenerationTurn, - ModelRequestPart, - ModelResponsePart, - TextPart, - ThinkingPart, + AssistantTextPart, + ContextPartChunk, TokenLogprob, ToolCallPart, - ToolReturnPart, - UserPromptPart, + ToolResultPart, + UserMessagePart, ) @@ -46,15 +42,17 @@ async def execute( task: BenchmarkTask, *, context: WorkerContext, - ) -> AsyncGenerator[GenerationTurn, None]: - for turn in _build_synthetic_turns(task.task_slug): - yield turn + ) -> AsyncGenerator[ContextPartChunk, None]: + for chunk in _build_synthetic_chunks(task.task_slug): + yield chunk -def _build_synthetic_turns(task_slug: str) -> list[GenerationTurn]: - """Generate 2-3 fake turns with synthetic logprobs.""" +def _build_synthetic_chunks(task_slug: str) -> list[ContextPartChunk]: + """Generate 2-3 fake turns worth of chunks with synthetic logprobs.""" num_turns = random.randint(2, 3) - turns: list[GenerationTurn] = [] + chunks: list[ContextPartChunk] = [ + ContextPartChunk(part=UserMessagePart(content=f"Task: Synthetic task {task_slug}")) + ] for i in range(num_turns): num_tokens = random.randint(8, 16) @@ -68,42 +66,31 @@ def _build_synthetic_turns(task_slug: str) -> list[GenerationTurn]: is_last = i == num_turns - 1 if not is_last: - response_parts = cast( - list[ModelResponsePart], - [ - ToolCallPart( + chunks.append( + ContextPartChunk( + part=ToolCallPart( tool_name="stub_tool", tool_call_id=f"call_{i}", args={"turn": i, "task": task_slug}, - ) - ], + ), + logprobs=logprobs, + ) ) - tool_results = [ - ToolReturnPart( - tool_call_id=f"call_{i}", - tool_name="stub_tool", - content=f"Tool result for turn {i} of {task_slug}", + chunks.append( + ContextPartChunk( + part=ToolResultPart( + tool_call_id=f"call_{i}", + tool_name="stub_tool", + content=f"Tool result for turn {i} of {task_slug}", + ) ) - ] - else: - response_parts = cast( - list[ModelResponsePart], - [TextPart(content=f"Synthetic response turn {i}")], ) - tool_results = [] - - messages_in: list[ModelRequestPart] = ( - [UserPromptPart(content=f"Task: Synthetic task {task_slug}")] if i == 0 else [] - ) - - turns.append( - GenerationTurn( - messages_in=messages_in, - response_parts=response_parts, - tool_results=tool_results, - turn_logprobs=logprobs, - policy_version="synthetic-v0", + else: + chunks.append( + ContextPartChunk( + part=AssistantTextPart(content=f"Synthetic response turn {i}"), + logprobs=logprobs, + ) ) - ) - return turns + return chunks diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py index 3c1b981a..9992ea54 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py @@ -10,7 +10,7 @@ from typing import ClassVar from uuid import UUID -from ergon_core.core.generation import GenerationTurn +from ergon_core.core.generation import ContextPartChunk from ergon_core.core.runtime.resources import RunResourceView from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext @@ -117,7 +117,7 @@ async def execute( task: BenchmarkTask, *, context: WorkerContext, - ) -> AsyncGenerator[GenerationTurn, None]: + ) -> AsyncGenerator[ContextPartChunk, None]: manager = ResearchRubricsSandboxManager() model_run_skill = make_run_skill(model=self.model) @@ -158,8 +158,8 @@ async def publisher_sync() -> list[RunResourceView]: ) self.tools = [*rr_tools, *graph_tools] - async for turn in super().execute(task, context=context): - yield turn + async for chunk in super().execute(task, context=context): + yield chunk async def _run_sandbox_report_skill( self, diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py index 7a7a3a05..d2e6b992 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py @@ -3,7 +3,7 @@ from typing import ClassVar from uuid import UUID -from ergon_core.core.generation import GenerationTurn +from ergon_core.core.generation import ContextPartChunk from ergon_core.core.runtime.resources import RunResourceView from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext @@ -125,7 +125,7 @@ async def execute( task: BenchmarkTask, *, context: WorkerContext, - ) -> AsyncGenerator[GenerationTurn, None]: + ) -> AsyncGenerator[ContextPartChunk, None]: manager = ResearchRubricsSandboxManager() model_run_skill = make_run_skill(model=self.model) @@ -164,8 +164,8 @@ async def publisher_sync() -> list[RunResourceView]: ) self.tools = [*rr_toolkit.build_tools(), *graph_toolkit.build_tools(), workflow_tool] - async for turn in super().execute(task, context=context): - yield turn + async for chunk in super().execute(task, context=context): + yield chunk async def _run_sandbox_report_skill( self, diff --git a/ergon_core/ergon_core/api/results.py b/ergon_core/ergon_core/api/results.py index b8e99c77..7d58216b 100644 --- a/ergon_core/ergon_core/api/results.py +++ b/ergon_core/ergon_core/api/results.py @@ -10,8 +10,8 @@ class WorkerOutput(BaseModel): """Final output of a worker execution. - The worker's ``execute()`` async generator yields ``GenerationTurn`` - objects (persisted individually to PG). After the generator exhausts, + The worker's ``execute()`` async generator yields ``ContextPartChunk`` + objects (enriched and persisted individually). After the generator exhausts, ``Worker.get_output()`` returns this model with the execution summary. """ diff --git a/ergon_core/ergon_core/api/worker.py b/ergon_core/ergon_core/api/worker.py index 10ea1835..28101daf 100644 --- a/ergon_core/ergon_core/api/worker.py +++ b/ergon_core/ergon_core/api/worker.py @@ -9,7 +9,7 @@ from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext -from ergon_core.core.generation import GenerationTurn +from ergon_core.core.generation import AssistantTextPart, ContextPartChunk from ergon_core.core.persistence.context.repository import ContextEventRepository from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.runtime.dependencies import check_packages @@ -20,7 +20,7 @@ class Worker(ABC): """Base class for all workers. Subclasses must set ``type_slug`` and implement ``execute`` as an - async generator that yields ``GenerationTurn`` objects. + async generator that yields ``ContextPartChunk`` objects. """ type_slug: ClassVar[str] @@ -54,12 +54,10 @@ async def execute( task: BenchmarkTask, *, context: WorkerContext, - ) -> AsyncGenerator[GenerationTurn, None]: - """Run the worker's task behavior, yielding turns as they complete. + ) -> AsyncGenerator[ContextPartChunk, None]: + """Run the worker's task behavior, yielding context chunks as they occur. - Each yielded GenerationTurn is persisted to PG immediately by the - runtime. Workers that can detect turn boundaries mid-execution - yield incrementally. Workers that can't yield all turns at the end. + Each yielded ContextPartChunk is enriched and persisted by the runtime. """ ... yield # type: ignore[misc] @@ -80,7 +78,7 @@ def from_buffer( return None def get_output(self, context: WorkerContext) -> WorkerOutput: - """Build output from persisted turns. Override for custom output. + """Build output from persisted context chunks. Override for custom output. Called by the runtime after the async generator is fully consumed. Default reads context events from PG via ``self._context_repo`` and returns @@ -89,11 +87,13 @@ def get_output(self, context: WorkerContext) -> WorkerOutput: """ with get_session() as session: events = self._context_repo.get_for_execution(session, context.execution_id) - text_events = [ - event.payload.get("text") - for event in events - if event.event_type == "assistant_text" and isinstance(event.payload.get("text"), str) - ] + text_events = [] + for event in events: + if event.event_type != "assistant_text": + continue + payload = event.parsed_payload() + if isinstance(payload.part, AssistantTextPart): + text_events.append(payload.part.content) return WorkerOutput( output=text_events[-1] if text_events else "", success=True, diff --git a/ergon_core/ergon_core/core/generation.py b/ergon_core/ergon_core/core/generation.py index af330b98..68e3a94a 100644 --- a/ergon_core/ergon_core/core/generation.py +++ b/ergon_core/ergon_core/core/generation.py @@ -1,7 +1,8 @@ -"""Core model-generation turn types. +"""Core model context-stream types. -These types are used by both public worker APIs and internal persistence. Keep -them in core so persistence can import them without loading ``ergon_core.api``. +These types are used by worker APIs, transcript adapters, persistence, replay, +and RL extraction. Keep them in core so persistence can import them without +loading ``ergon_core.api``. """ from datetime import datetime @@ -23,66 +24,78 @@ class TokenLogprob(BaseModel): class SystemPromptPart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["system-prompt"] = "system-prompt" + part_kind: Literal["system_prompt"] = "system_prompt" content: str -class UserPromptPart(BaseModel): +class UserMessagePart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["user-prompt"] = "user-prompt" + part_kind: Literal["user_message"] = "user_message" content: str -class ToolReturnPart(BaseModel): +class AssistantTextPart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["tool-return"] = "tool-return" - tool_call_id: str - tool_name: str - content: str - - -ModelRequestPart = Annotated[ - SystemPromptPart | UserPromptPart | ToolReturnPart, - Field(discriminator="part_kind"), -] - - -class TextPart(BaseModel): - model_config = {"frozen": True} - part_kind: Literal["text"] = "text" + part_kind: Literal["assistant_text"] = "assistant_text" content: str class ToolCallPart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["tool-call"] = "tool-call" + part_kind: Literal["tool_call"] = "tool_call" tool_name: str tool_call_id: str args: dict[str, Any] # slopcop: ignore[no-typing-any] +class ToolResultPart(BaseModel): + model_config = {"frozen": True} + part_kind: Literal["tool_result"] = "tool_result" + tool_call_id: str + tool_name: str + content: str + is_error: bool = False + + class ThinkingPart(BaseModel): model_config = {"frozen": True} part_kind: Literal["thinking"] = "thinking" content: str -ModelResponsePart = Annotated[ - TextPart | ToolCallPart | ThinkingPart, +ContextPart = Annotated[ + SystemPromptPart + | UserMessagePart + | AssistantTextPart + | ToolCallPart + | ToolResultPart + | ThinkingPart, Field(discriminator="part_kind"), ] -class GenerationTurn(BaseModel): - """One model generation turn within a worker episode.""" +class ContextPartChunk(BaseModel): + """One worker-emitted context/action stream item. + + Core adds run/execution/sequence/timing metadata before persistence. + """ model_config = {"frozen": True} - messages_in: list[ModelRequestPart] = Field(default_factory=list) - response_parts: list[ModelResponsePart] = Field(default_factory=list) - tool_results: list[ToolReturnPart] = Field(default_factory=list) - turn_token_ids: list[int] | None = None - turn_logprobs: list[TokenLogprob] | None = None - policy_version: str | None = None + part: ContextPart + token_ids: list[int] | None = None + logprobs: list[TokenLogprob] | None = None + + +class ContextPartChunkLog(ContextPartChunk): + """Core-enriched context stream item suitable for API/dashboard projection.""" + + sequence: int + worker_binding_key: str + turn_id: str | None = None started_at: datetime | None = None completed_at: datetime | None = None + policy_version: str | None = None + + +WorkerYield = ContextPartChunk diff --git a/ergon_core/ergon_core/core/persistence/context/event_payloads.py b/ergon_core/ergon_core/core/persistence/context/event_payloads.py index 57f4882d..d5286dd5 100644 --- a/ergon_core/ergon_core/core/persistence/context/event_payloads.py +++ b/ergon_core/ergon_core/core/persistence/context/event_payloads.py @@ -1,16 +1,13 @@ -# ergon_core/ergon_core/core/persistence/context/event_payloads.py -"""Typed discriminated-union payloads for run_context_events rows. +"""Typed context event payload exports. -Pattern mirrors GraphMutationValue in graph_dto.py — embed event_type as -a Literal field so Pydantic can discriminate on deserialisation. +The canonical context payload is an enriched ContextPartChunkLog. Event-specific +payload classes were removed in favor of ContextPartChunkLog.part. """ -from typing import Annotated, Any, Literal +from typing import Literal -from ergon_core.core.generation import TokenLogprob -from pydantic import BaseModel, Field +from ergon_core.core.generation import ContextPart, ContextPartChunk, ContextPartChunkLog -# Exported type alias — use everywhere event_type is stored as a string field. ContextEventType = Literal[ "system_prompt", "user_message", @@ -20,122 +17,4 @@ "thinking", ] - -class SystemPromptPayload(BaseModel): - event_type: Literal["system_prompt"] = "system_prompt" - text: str - - -class UserMessagePayload(BaseModel): - event_type: Literal["user_message"] = "user_message" - text: str - from_worker_key: str | None = Field( - default=None, - description=( - "Worker binding key when this message was sent by another agent instead of " - "the external user." - ), - ) - - -class AssistantTextPayload(BaseModel): - event_type: Literal["assistant_text"] = "assistant_text" - text: str - turn_id: str = Field( - description=( - "Generation turn identifier that groups model-output events from the same " - "single synchronous agent run." - ) - ) - turn_token_ids: list[int] | None = Field( - default=None, - description=( - "Token ids for the generation turn. Present only on the first model-output " - "event so sibling events can share the turn-level token stream." - ), - ) - turn_logprobs: list[TokenLogprob] | None = Field( - default=None, - description=( - "Token logprobs for the generation turn. Present only on the first " - "model-output event so sibling events can share the turn-level logprob stream." - ), - ) - - -class ToolCallPayload(BaseModel): - event_type: Literal["tool_call"] = "tool_call" - tool_call_id: str - tool_name: str - args: dict[str, Any] # slopcop: ignore[no-typing-any] - turn_id: str = Field( - description=( - "Generation turn identifier that groups this tool call with other events " - "emitted by the same single synchronous agent run." - ) - ) - turn_token_ids: list[int] | None = Field( - default=None, - description=( - "Token ids for the generation turn, omitted when another event in this turn " - "carries the shared token stream." - ), - ) - turn_logprobs: list[TokenLogprob] | None = Field( - default=None, - description=( - "Token logprobs for the generation turn, omitted when another event in this " - "turn carries the shared logprob stream." - ), - ) - - -class ToolResultPayload(BaseModel): - event_type: Literal["tool_result"] = "tool_result" - tool_call_id: str = Field( - description="Identifier linking this result back to the ToolCallPayload it answers." - ) - tool_name: str - result: Any = Field( # slopcop: ignore[no-typing-any] - description=( - "Open JSON-serializable value returned by the tool call; intentionally accepts " - "any persisted result shape." - ) - ) - is_error: bool = False - - -class ThinkingPayload(BaseModel): - event_type: Literal["thinking"] = "thinking" - text: str - turn_id: str = Field( - description=( - "Generation turn identifier that groups thinking text with other events from " - "the same single synchronous agent run." - ) - ) - turn_token_ids: list[int] | None = Field( - default=None, - description=( - "Token ids for the generation turn. Present only on the first model-output " - "event so sibling events can share the turn-level token stream." - ), - ) - turn_logprobs: list[TokenLogprob] | None = Field( - default=None, - description=( - "Token logprobs for the generation turn. Present only on the first " - "model-output event so sibling events can share the turn-level logprob stream." - ), - ) - - -ContextEventPayload = Annotated[ - SystemPromptPayload - | UserMessagePayload - | AssistantTextPayload - | ToolCallPayload - | ToolResultPayload - | ThinkingPayload, - Field(discriminator="event_type"), -] +ContextEventPayload = ContextPartChunkLog diff --git a/ergon_core/ergon_core/core/persistence/context/models.py b/ergon_core/ergon_core/core/persistence/context/models.py index 548e10f5..786152c4 100644 --- a/ergon_core/ergon_core/core/persistence/context/models.py +++ b/ergon_core/ergon_core/core/persistence/context/models.py @@ -6,7 +6,7 @@ from uuid import UUID import sqlalchemy as sa -from ergon_core.core.persistence.context.event_payloads import ContextEventPayload +from ergon_core.core.generation import ContextPartChunkLog from ergon_core.core.persistence.shared.ids import new_id from pydantic import TypeAdapter from sqlalchemy import JSON, Column, DateTime @@ -19,7 +19,7 @@ def _utcnow() -> datetime: return datetime.now(UTC) -_PAYLOAD_ADAPTER: TypeAdapter[ContextEventPayload] = TypeAdapter(ContextEventPayload) +_PAYLOAD_ADAPTER: TypeAdapter[ContextPartChunkLog] = TypeAdapter(ContextPartChunkLog) class RunContextEvent(SQLModel, table=True): @@ -51,5 +51,5 @@ class RunContextEvent(SQLModel, table=True): created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) policy_version: str | None = None - def parsed_payload(self) -> ContextEventPayload: + def parsed_payload(self) -> ContextPartChunkLog: return _PAYLOAD_ADAPTER.validate_python(self.payload) diff --git a/ergon_core/ergon_core/core/persistence/context/repository.py b/ergon_core/ergon_core/core/persistence/context/repository.py index bd676f35..2db7ec29 100644 --- a/ergon_core/ergon_core/core/persistence/context/repository.py +++ b/ergon_core/ergon_core/core/persistence/context/repository.py @@ -7,26 +7,18 @@ import logging from collections.abc import Awaitable, Callable -from datetime import datetime +from datetime import UTC, datetime from uuid import UUID, uuid4 from ergon_core.core.generation import ( - GenerationTurn, + AssistantTextPart, + ContextPartChunk, + ContextPartChunkLog, SystemPromptPart, - TextPart, ThinkingPart, ToolCallPart, - ToolReturnPart, - UserPromptPart, -) -from ergon_core.core.persistence.context.event_payloads import ( - AssistantTextPayload, - ContextEventPayload, - SystemPromptPayload, - ThinkingPayload, - ToolCallPayload, - ToolResultPayload, - UserMessagePayload, + ToolResultPart, + UserMessagePart, ) from ergon_core.core.persistence.context.models import RunContextEvent from sqlmodel import Session, select @@ -40,6 +32,7 @@ class ContextEventRepository: def __init__(self) -> None: self._listeners: list[Callable[[RunContextEvent], Awaitable[None]]] = [] self._sequence_counters: dict[UUID, int] = {} + self._active_turn_ids: dict[UUID, str] = {} def add_listener(self, listener: Callable[[RunContextEvent], Awaitable[None]]) -> None: self._listeners.append(listener) @@ -53,7 +46,7 @@ def _make_event( execution_id: UUID, worker_binding_key: str, sequence: int, - payload: ContextEventPayload, + payload: ContextPartChunkLog, *, started_at: datetime | None = None, completed_at: datetime | None = None, @@ -64,174 +57,76 @@ def _make_event( task_execution_id=execution_id, worker_binding_key=worker_binding_key, sequence=sequence, - event_type=payload.event_type, + event_type=payload.part.part_kind, payload=payload.model_dump(mode="json"), started_at=started_at, completed_at=completed_at, policy_version=policy_version, ) - def _events_from_request_parts( - self, - run_id: UUID, - execution_id: UUID, - worker_binding_key: str, - turn: GenerationTurn, - seq: int, - ) -> tuple[list[RunContextEvent], int]: - """Produce context events from messages_in (excluding ToolReturnParts).""" - events: list[RunContextEvent] = [] - for part in turn.messages_in: - if isinstance(part, SystemPromptPart): - events.append( - self._make_event( - run_id, - execution_id, - worker_binding_key, - seq, - SystemPromptPayload(text=part.content), - ) - ) - seq += 1 - elif isinstance(part, UserPromptPart): - events.append( - self._make_event( - run_id, - execution_id, - worker_binding_key, - seq, - UserMessagePayload(text=part.content), - ) - ) - seq += 1 - return events, seq - - def _events_from_response_parts( - self, - run_id: UUID, - execution_id: UUID, - worker_binding_key: str, - turn: GenerationTurn, - seq: int, - turn_id: str, - ) -> tuple[list[RunContextEvent], int]: - """Produce context events from response_parts (model-generated output).""" - events: list[RunContextEvent] = [] - token_ids = turn.turn_token_ids - logprobs = turn.turn_logprobs - for part in turn.response_parts: - payload: ContextEventPayload - if isinstance(part, ThinkingPart): - payload = ThinkingPayload( - text=part.content, - turn_id=turn_id, - turn_token_ids=token_ids, - turn_logprobs=logprobs, - ) - elif isinstance(part, TextPart): - payload = AssistantTextPayload( - text=part.content, - turn_id=turn_id, - turn_token_ids=token_ids, - turn_logprobs=logprobs, - ) - elif isinstance(part, ToolCallPart): - payload = ToolCallPayload( - tool_call_id=part.tool_call_id, - tool_name=part.tool_name, - args=part.args, - turn_id=turn_id, - turn_token_ids=token_ids, - turn_logprobs=logprobs, - ) - else: - continue - events.append( - self._make_event( - run_id, - execution_id, - worker_binding_key, - seq, - payload, - started_at=turn.started_at, - completed_at=turn.completed_at, - policy_version=turn.policy_version, - ) - ) - token_ids = None - logprobs = None - seq += 1 - return events, seq - - def _events_from_tool_results( - self, - run_id: UUID, - execution_id: UUID, - worker_binding_key: str, - turn: GenerationTurn, - seq: int, - ) -> tuple[list[RunContextEvent], int]: - """Produce tool_result events from GenerationTurn tool observations.""" - events: list[RunContextEvent] = [] - tool_result_parts = turn.tool_results or [ - part for part in turn.messages_in if isinstance(part, ToolReturnPart) - ] - for part in tool_result_parts: - events.append( - self._make_event( - run_id, - execution_id, - worker_binding_key, - seq, - ToolResultPayload( - tool_call_id=part.tool_call_id, - tool_name=part.tool_name, - result=part.content, - # TODO: Set is_error=True when ToolReturnPart gains an is_error field (currently always False) - ), - ) - ) - seq += 1 - return events, seq - - async def persist_turn( + def _turn_id_for_chunk(self, execution_id: UUID, chunk: ContextPartChunk) -> str | None: + part = chunk.part + if isinstance(part, (AssistantTextPart, ThinkingPart, ToolCallPart)): + turn_id = self._active_turn_ids.get(execution_id) + if turn_id is None: + turn_id = str(uuid4()) + self._active_turn_ids[execution_id] = turn_id + return turn_id + if isinstance(part, (SystemPromptPart, UserMessagePart, ToolResultPart)): + self._active_turn_ids.pop(execution_id, None) + return None + return None + + async def persist_chunk( self, session: Session, *, run_id: UUID, execution_id: UUID, worker_binding_key: str, - turn: GenerationTurn, - ) -> list[RunContextEvent]: - """Decompose one GenerationTurn into ordered context events and persist them.""" + chunk: ContextPartChunk, + started_at: datetime | None = None, + completed_at: datetime | None = None, + policy_version: str | None = None, + ) -> RunContextEvent: + """Enrich and persist one worker-emitted context stream chunk.""" seq = self._next_sequence(execution_id) - turn_id = str(uuid4()) - - req_events, seq = self._events_from_request_parts( - run_id, execution_id, worker_binding_key, turn, seq - ) - resp_events, seq = self._events_from_response_parts( - run_id, execution_id, worker_binding_key, turn, seq, turn_id + now = datetime.now(UTC) + event_started_at = started_at or now + event_completed_at = completed_at or now + payload = ContextPartChunkLog( + part=chunk.part, + token_ids=chunk.token_ids, + logprobs=chunk.logprobs, + sequence=seq, + worker_binding_key=worker_binding_key, + turn_id=self._turn_id_for_chunk(execution_id, chunk), + started_at=event_started_at, + completed_at=event_completed_at, + policy_version=policy_version, ) - tool_events, seq = self._events_from_tool_results( - run_id, execution_id, worker_binding_key, turn, seq + event = self._make_event( + run_id, + execution_id, + worker_binding_key, + seq, + payload, + started_at=payload.started_at, + completed_at=payload.completed_at, + policy_version=payload.policy_version, ) + self._sequence_counters[execution_id] = seq + 1 - events = req_events + resp_events + tool_events - self._sequence_counters[execution_id] = seq - - for event in events: - session.add(event) + session.add(event) session.commit() - for event in events: - for listener in self._listeners: - try: - await listener(event) - except Exception: # slopcop: ignore[no-broad-except] - logger.warning("Context event listener failed", exc_info=True) + for listener in self._listeners: + try: + await listener(event) + except Exception: # slopcop: ignore[no-broad-except] + logger.warning("Context event listener failed", exc_info=True) - return events + return event def get_for_execution(self, session: Session, execution_id: UUID) -> list[RunContextEvent]: stmt = ( diff --git a/ergon_core/ergon_core/core/rl/extraction.py b/ergon_core/ergon_core/core/rl/extraction.py index 369f0f64..584b6bea 100644 --- a/ergon_core/ergon_core/core/rl/extraction.py +++ b/ergon_core/ergon_core/core/rl/extraction.py @@ -14,13 +14,14 @@ from collections import defaultdict from typing import Protocol, runtime_checkable -from ergon_core.core.persistence.context.event_payloads import ( - AssistantTextPayload, - SystemPromptPayload, - ThinkingPayload, - ToolCallPayload, - ToolResultPayload, - UserMessagePayload, +from ergon_core.core.generation import ( + AssistantTextPart, + ContextPartChunkLog, + SystemPromptPart, + ThinkingPart, + ToolCallPart, + ToolResultPart, + UserMessagePart, ) from ergon_core.core.persistence.context.models import RunContextEvent from ergon_core.core.rl.rewards import IndependentTaskReward, RewardStrategy @@ -78,24 +79,21 @@ def extract_agent_trajectories( for event in events: parsed = event.parsed_payload() + part = parsed.part execution_ids.add(str(event.task_execution_id)) - if event.event_type in ("system_prompt", "user_message"): + if isinstance(part, (SystemPromptPart, UserMessagePart)): continue # prompt context — not in completion - if event.event_type in ("assistant_text", "tool_call", "thinking"): + if isinstance(part, (AssistantTextPart, ToolCallPart, ThinkingPart)): token_ids = _get_token_ids(parsed, tokenizer) token_logprobs = _get_logprobs(parsed, len(token_ids)) completion_ids.extend(token_ids) logprobs.extend(token_logprobs) env_mask.extend([1] * len(token_ids)) - elif event.event_type == "tool_result": - if not isinstance(parsed, ToolResultPayload): - raise ValueError( - f"Expected ToolResultPayload for tool_result event, got {type(parsed)}" - ) - result_tokens = tokenizer.encode(str(parsed.result)) + elif isinstance(part, ToolResultPart): + result_tokens = tokenizer.encode(part.content) completion_ids.extend(result_tokens) logprobs.extend([0.0] * len(result_tokens)) env_mask.extend([0] * len(result_tokens)) @@ -120,61 +118,37 @@ def extract_agent_trajectories( def _build_prompt_text(events: list[RunContextEvent]) -> str: parts: list[str] = [] for event in events: - if event.event_type == "system_prompt": - p = event.parsed_payload() - if not isinstance(p, SystemPromptPayload): - raise ValueError( - f"Expected SystemPromptPayload for system_prompt event, got {type(p)}" - ) - parts.append(p.text) - elif event.event_type == "user_message": - p = event.parsed_payload() - if not isinstance(p, UserMessagePayload): - raise ValueError( - f"Expected UserMessagePayload for user_message event, got {type(p)}" - ) - parts.append(p.text) - elif event.event_type in ("assistant_text", "tool_call", "thinking", "tool_result"): + payload = event.parsed_payload() + part = payload.part + if isinstance(part, SystemPromptPart): + parts.append(part.content) + elif isinstance(part, UserMessagePart): + parts.append(part.content) + elif isinstance(part, (AssistantTextPart, ToolCallPart, ThinkingPart, ToolResultPart)): break return "\n\n".join(parts) -def _get_token_ids( - parsed: AssistantTextPayload | ToolCallPayload | ThinkingPayload, tokenizer: Tokenizer -) -> list[int]: +def _get_token_ids(parsed: ContextPartChunkLog, tokenizer: Tokenizer) -> list[int]: """Return token IDs for a model-generated event. - Uses turn_token_ids if present (vLLM path). Falls back to tokenising text content. - NOTE: For multi-event turns, turn_token_ids covers ALL tokens in generation order. - Slicing per-event is only correct for single-event turns. + Uses token_ids if present. Falls back to tokenising the part content. """ - if isinstance(parsed, AssistantTextPayload): - return ( - parsed.turn_token_ids - if parsed.turn_token_ids is not None - else tokenizer.encode(parsed.text) - ) - if isinstance(parsed, ToolCallPayload): - args_text = json.dumps(parsed.args) - return ( - parsed.turn_token_ids - if parsed.turn_token_ids is not None - else tokenizer.encode(args_text) - ) - if isinstance(parsed, ThinkingPayload): - return ( - parsed.turn_token_ids - if parsed.turn_token_ids is not None - else tokenizer.encode(parsed.text) - ) + if parsed.token_ids is not None: + return parsed.token_ids + part = parsed.part + if isinstance(part, AssistantTextPart): + return tokenizer.encode(part.content) + if isinstance(part, ToolCallPart): + return tokenizer.encode(json.dumps(part.args)) + if isinstance(part, ThinkingPart): + return tokenizer.encode(part.content) raise ValueError(f"_get_token_ids called on non-model event: {type(parsed)}") -def _get_logprobs( - parsed: AssistantTextPayload | ToolCallPayload | ThinkingPayload, n_tokens: int -) -> list[float]: +def _get_logprobs(parsed: ContextPartChunkLog, n_tokens: int) -> list[float]: """Return per-token logprob scalars, padding with 0.0 if unavailable.""" - lps = parsed.turn_logprobs + lps = parsed.logprobs if lps is None: return [0.0] * n_tokens scalars = [lp.logprob for lp in lps] @@ -186,8 +160,8 @@ def _get_logprobs( def _count_turns(events: list[RunContextEvent]) -> int: seen: set[str] = set() for event in events: - if event.event_type in ("assistant_text", "tool_call", "thinking"): - parsed = event.parsed_payload() - if isinstance(parsed, (AssistantTextPayload, ToolCallPayload, ThinkingPayload)): + parsed = event.parsed_payload() + if isinstance(parsed.part, (AssistantTextPart, ToolCallPart, ThinkingPart)): + if parsed.turn_id is not None: seen.add(parsed.turn_id) return len(seen) diff --git a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py index b29540f0..5d48d0cd 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py +++ b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py @@ -2,7 +2,7 @@ Looks up the registered worker, constructs a BenchmarkTask, and runs execute(). Consumes the async generator, persisting context events to PG via the -ContextEventRepository. Dashboard events are emitted per-turn via the +ContextEventRepository. Dashboard events are emitted per chunk via the repository listener pattern. """ @@ -15,7 +15,7 @@ from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload from ergon_core.api.worker_context import WorkerContext from ergon_core.core.dashboard.emitter import dashboard_emitter -from ergon_core.core.generation import GenerationTurn +from ergon_core.core.generation import ContextPartChunk from ergon_core.core.persistence.context.repository import ContextEventRepository from ergon_core.core.persistence.queries import queries from ergon_core.core.persistence.shared.db import get_session @@ -116,34 +116,25 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: task_node_id=payload.node_id, ) - turn_count = 0 + chunk_count = 0 try: - turn_start = datetime.now(UTC) - async for turn in worker.execute(task, context=worker_context): - turn_end = datetime.now(UTC) - turn = turn.model_copy( - update={ - "started_at": turn.started_at or turn_start, - "completed_at": turn.completed_at or turn_end, - } - ) + async for chunk in worker.execute(task, context=worker_context): await _persist_context_events( context_event_repo, payload, - turn, - turn_count, + chunk, + chunk_count, ) - turn_count += 1 - turn_start = datetime.now(UTC) + chunk_count += 1 output = worker.get_output(worker_context) except Exception as exc: # slopcop: ignore[no-broad-except] error_msg = str(exc) logger.exception( - "worker-execute failed task_id=%s after %d turns: %s", + "worker-execute failed task_id=%s after %d chunks: %s", payload.task_id, - turn_count, + chunk_count, error_msg, ) return _worker_execute_result_from_exception(exc) @@ -168,7 +159,7 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: "model_target": payload.model_target, "success": output.success, "output_length": len(output.output), - "turn_count": turn_count, + "chunk_count": chunk_count, }, ) ) @@ -179,24 +170,23 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: async def _persist_context_events( context_event_repo: ContextEventRepository, payload: WorkerExecuteRequest, - turn: GenerationTurn, - turn_count: int, + chunk: ContextPartChunk, + chunk_count: int, ) -> None: - """Persist context events for a single turn, swallowing failures so they - never interrupt the primary generation turn write.""" + """Persist one context chunk, swallowing failures so worker execution continues.""" try: with get_session() as session: - await context_event_repo.persist_turn( + await context_event_repo.persist_chunk( session, run_id=payload.run_id, execution_id=payload.execution_id, worker_binding_key=payload.assigned_worker_slug, - turn=turn, + chunk=chunk, ) except Exception: # slopcop: ignore[no-broad-except] logger.warning( - "context event persist failed for execution %s turn %d", + "context event persist failed for execution %s chunk %d", payload.execution_id, - turn_count, + chunk_count, exc_info=True, ) diff --git a/ergon_core/ergon_core/core/runtime/tracing.py b/ergon_core/ergon_core/core/runtime/tracing.py index 927e7d60..32210b37 100644 --- a/ergon_core/ergon_core/core/runtime/tracing.py +++ b/ergon_core/ergon_core/core/runtime/tracing.py @@ -18,8 +18,8 @@ │ instance_key │ ├── sandbox.setup │ ├── worker.execute - │ │ └── tool.{tool_name} (per tool call in GenerationTurn) - │ │ turn_index, tool_name, tool_call_id, has_result + │ │ └── tool.{tool_name} (per tool call context part) + │ │ sequence, tool_name, tool_call_id, has_result │ ├── persist.outputs │ │ resource_ids │ └── evaluation.task (per evaluator) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py index cd653656..aa9f8fa2 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py @@ -11,7 +11,7 @@ 3. Post a one-line completion message to the shared ``smoke-completion`` thread so the driver can assert on message ordering + thread-FK integrity. - 4. Yield 2 ``GenerationTurn`` objects (attach → done). + 4. Yield 2 ``ContextPartChunk`` objects (attach → done). Sad-path leaves (``AlwaysFailSubworker`` in Phase C) raise inside ``subworker.work()``, so they never reach ``_send_completion_message`` @@ -25,7 +25,7 @@ from uuid import UUID from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.core.generation import GenerationTurn, TextPart +from ergon_core.core.generation import AssistantTextPart, ContextPartChunk from ergon_core.api.results import WorkerOutput from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.db import get_session @@ -49,7 +49,7 @@ class BaseSmokeLeafWorker(Worker): # leaf's ``execute`` instantiates ``subworker_cls()`` and delegates. subworker_cls: ClassVar[type[SmokeSubworker]] - # Driver asserts per-leaf GenerationTurn count against this constant. + # Driver asserts per-leaf context chunk count against this constant. # Sad-path leaves that raise inside subworker.work() emit fewer turns # (only the first 'attaching' turn) and are skipped from the strict # equality check on the sad run. @@ -71,19 +71,17 @@ async def execute( task: BenchmarkTask, *, context: WorkerContext, - ) -> AsyncGenerator[GenerationTurn, None]: + ) -> AsyncGenerator[ContextPartChunk, None]: node_hex = context.node_id.hex[:8] if context.node_id else "unknown" # --- Turn 1: attaching + starting --------------------------------- - yield GenerationTurn( - response_parts=[ - TextPart( - content=( - f"{type(self).__name__}: attaching to sandbox " - f"{context.sandbox_id} for node={node_hex}" - ), + yield ContextPartChunk( + part=AssistantTextPart( + content=( + f"{type(self).__name__}: attaching to sandbox " + f"{context.sandbox_id} for node={node_hex}" ), - ], + ), ) raw_sandbox = await SmokeSandboxManager().reconnect(context.sandbox_id) @@ -104,15 +102,13 @@ async def execute( await self._send_completion_message(context, result) # --- Turn 2: done + result summary -------------------------------- - yield GenerationTurn( - response_parts=[ - TextPart( - content=( - f"{type(self).__name__}: done node={node_hex} " - f"file={result.file_path} probe_exit={result.probe_exit_code}" - ), + yield ContextPartChunk( + part=AssistantTextPart( + content=( + f"{type(self).__name__}: done node={node_hex} " + f"file={result.file_path} probe_exit={result.probe_exit_code}" ), - ], + ), ) def get_output(self, context: WorkerContext) -> WorkerOutput: diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py index a6d664d2..7e15ec30 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py @@ -12,7 +12,7 @@ from uuid import UUID from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.core.generation import GenerationTurn, TextPart +from ergon_core.core.generation import AssistantTextPart, ContextPartChunk from ergon_core.api.results import WorkerOutput from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES @@ -46,19 +46,17 @@ async def execute( task: BenchmarkTask, *, context: WorkerContext, - ) -> AsyncGenerator[GenerationTurn, None]: + ) -> AsyncGenerator[ContextPartChunk, None]: if context.node_id is None: raise ValueError(f"{type(self).__name__} requires context.node_id") - yield GenerationTurn( - response_parts=[ - TextPart( - content=( - f"{type(self).__name__}: planning nested " - f"{' -> '.join(NESTED_LINE_SLUGS)} via leaf={self.leaf_slug}" - ), + yield ContextPartChunk( + part=AssistantTextPart( + content=( + f"{type(self).__name__}: planning nested " + f"{' -> '.join(NESTED_LINE_SLUGS)} via leaf={self.leaf_slug}" ), - ], + ), ) specs = [ @@ -84,12 +82,10 @@ async def execute( f"{slug}: planned (node_id={result.nodes[TaskSlug(slug)]})" for slug, _deps, _desc in NESTED_SUBTASK_GRAPH ) - yield GenerationTurn( - response_parts=[ - TextPart( - content=f"{type(self).__name__}: nested subtasks planned:\n{summary}", - ), - ], + yield ContextPartChunk( + part=AssistantTextPart( + content=f"{type(self).__name__}: nested subtasks planned:\n{summary}", + ), ) inspection = TaskInspectionService() @@ -106,15 +102,13 @@ async def execute( await asyncio.sleep(2) await self._send_recursive_completion_message(context) - yield GenerationTurn( - response_parts=[ - TextPart( - content=( - f"{type(self).__name__}: nested children terminal " - f"{self._last_child_statuses}" - ), + yield ContextPartChunk( + part=AssistantTextPart( + content=( + f"{type(self).__name__}: nested children terminal " + f"{self._last_child_statuses}" ), - ], + ), ) def get_output(self, context: WorkerContext) -> WorkerOutput: diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py index e9f97203..c603d2ad 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py @@ -7,8 +7,8 @@ override hook so the sad-path subclass can send one slug to a failing leaf without touching topology. -Parent yields 3 ``GenerationTurn`` objects (planning → planned → -awaiting) so every smoke run exercises the incremental turn persistence +Parent yields 3 ``ContextPartChunk`` objects (planning → planned → +awaiting) so every smoke run exercises the incremental chunk persistence path at realistic volume. See ``docs/superpowers/plans/test-refactor/01-fixtures.md §2.3``. """ @@ -18,7 +18,7 @@ from typing import ClassVar, final from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.core.generation import GenerationTurn, TextPart +from ergon_core.core.generation import AssistantTextPart, ContextPartChunk from ergon_core.api.results import WorkerOutput from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES from ergon_core.core.persistence.shared.db import get_session @@ -57,7 +57,7 @@ class SmokeWorkerBase(Worker): # by default. leaf_slug: ClassVar[str] - # Driver asserts per-run GenerationTurn count against this constant + # Driver asserts per-run context chunk count against this constant # (see tests/e2e/_asserts.py ``_assert_run_turn_counts``). PARENT_TURN_COUNT: ClassVar[int] = 3 @@ -71,20 +71,18 @@ async def execute( task: BenchmarkTask, *, context: WorkerContext, - ) -> AsyncGenerator[GenerationTurn, None]: + ) -> AsyncGenerator[ContextPartChunk, None]: if context.node_id is None: raise ValueError(f"{type(self).__name__} requires context.node_id") # --- Turn 1: planning announcement (pre-service-call) ------------- - yield GenerationTurn( - response_parts=[ - TextPart( - content=( - f"{type(self).__name__}: planning 9 subtasks " - f"(diamond+line+singletons) → leaf slug={self.leaf_slug}" - ), + yield ContextPartChunk( + part=AssistantTextPart( + content=( + f"{type(self).__name__}: planning 9 subtasks " + f"(diamond+line+singletons) → leaf slug={self.leaf_slug}" ), - ], + ), ) # Per-slug spec construction goes through ``_spec_for`` so sad-path @@ -106,27 +104,23 @@ async def execute( f"{slug}: planned (node_id={result.nodes[TaskSlug(slug)]})" for slug, _deps, _desc in SUBTASK_GRAPH ) - yield GenerationTurn( - response_parts=[ - TextPart( - content=( - f"{type(self).__name__}: 9 subtasks planned " - f"(roots={sorted(result.roots)}):\n{summary}" - ), + yield ContextPartChunk( + part=AssistantTextPart( + content=( + f"{type(self).__name__}: 9 subtasks planned " + f"(roots={sorted(result.roots)}):\n{summary}" ), - ], + ), ) # --- Turn 3: awaiting children (terminal) ------------------------- - yield GenerationTurn( - response_parts=[ - TextPart( - content=( - f"{type(self).__name__}: awaiting 9 children — " - "runtime will mark parent COMPLETED once wait_all resolves" - ), + yield ContextPartChunk( + part=AssistantTextPart( + content=( + f"{type(self).__name__}: awaiting 9 children — " + "runtime will mark parent COMPLETED once wait_all resolves" ), - ], + ), ) # Poll until every direct child has reached a terminal status. diff --git a/tests/e2e/_asserts.py b/tests/e2e/_asserts.py index 418c4755..24d68259 100644 --- a/tests/e2e/_asserts.py +++ b/tests/e2e/_asserts.py @@ -128,11 +128,10 @@ def _assert_run_resources(run_id: UUID) -> None: def _assert_run_turn_counts(run_id: UUID) -> None: - """Parent + recursive ``l_2`` + artifact leaves emit fixed turn counts. + """Parent + recursive ``l_2`` + artifact leaves emit fixed chunk counts. - Each smoke ``GenerationTurn`` has ``messages_in=[]`` and one ``TextPart`` - in ``response_parts``, so ``persist_turn`` emits exactly 1 ``RunContextEvent`` - per turn. + Each smoke context chunk contains one assistant text part, so persistence + emits exactly one ``RunContextEvent`` per chunk. """ leaf_count = len(EXPECTED_SUBTASK_SLUGS) - 1 + len(NESTED_LINE_SLUGS) expected = ( diff --git a/tests/unit/architecture/test_core_schema_sources.py b/tests/unit/architecture/test_core_schema_sources.py index ca5b349c..836d9072 100644 --- a/tests/unit/architecture/test_core_schema_sources.py +++ b/tests/unit/architecture/test_core_schema_sources.py @@ -98,3 +98,28 @@ def test_core_schema_source_imports_are_directional() -> None: offenders.append(f"{path.relative_to(ROOT)} contains local source {snippet!r}") assert offenders == [] + + +def test_context_stream_has_single_discriminated_part_union() -> None: + generation = ROOT / "ergon_core/ergon_core/core/generation.py" + event_payloads = ROOT / "ergon_core/ergon_core/core/persistence/context/event_payloads.py" + + generation_text = generation.read_text() + event_payloads_text = event_payloads.read_text() + + assert "ContextPart = Annotated[" in generation_text + old_generation_names = ( + "Generation" + "Turn", + "ModelRequest" + "Part", + "ModelResponse" + "Part", + ) + old_payload_names = ( + "SystemPrompt" + "Payload", + "AssistantText" + "Payload", + "ToolCall" + "Payload", + ) + + for name in old_generation_names: + assert name not in generation_text + for name in old_payload_names: + assert name not in event_payloads_text diff --git a/tests/unit/builtins/common/test_transcript_adapters.py b/tests/unit/builtins/common/test_transcript_adapters.py index f7092cd8..ad808705 100644 --- a/tests/unit/builtins/common/test_transcript_adapters.py +++ b/tests/unit/builtins/common/test_transcript_adapters.py @@ -1,27 +1,19 @@ from uuid import uuid4 -from ergon_builtins.common.llm_context.adapters.base import TranscriptAdapter from ergon_builtins.common.llm_context.adapters.pydantic_ai import ( PydanticAITranscriptAdapter, TranscriptTurnCursor, ) from ergon_core.core.generation import ( - GenerationTurn, - TextPart as ErgonTextPart, + AssistantTextPart, + ContextPartChunkLog, + SystemPromptPart as ErgonSystemPromptPart, ThinkingPart as ErgonThinkingPart, ToolCallPart as ErgonToolCallPart, - ToolReturnPart as ErgonToolReturnPart, - UserPromptPart as ErgonUserPromptPart, -) -from ergon_core.core.persistence.context.event_payloads import ( - AssistantTextPayload, - ContextEventType, - SystemPromptPayload, - ThinkingPayload, - ToolCallPayload, - ToolResultPayload, - UserMessagePayload, + ToolResultPart as ErgonToolResultPart, + UserMessagePart as ErgonUserMessagePart, ) +from ergon_core.core.persistence.context.event_payloads import ContextEventType from ergon_core.core.persistence.context.models import RunContextEvent from pydantic_ai.messages import ( ModelRequest, @@ -33,38 +25,36 @@ ToolReturnPart, UserPromptPart, ) -from pydantic_ai.messages import ( - TextPart as PydanticTextPart, -) -from pydantic_ai.messages import ( - ThinkingPart as PydanticThinkingPart, -) -from pydantic_ai.messages import ( - ToolCallPart as PydanticToolCallPart, -) -from pydantic_ai.messages import ( - ToolReturnPart as PydanticToolReturnPart, -) +from pydantic_ai.messages import TextPart as PydanticTextPart +from pydantic_ai.messages import ThinkingPart as PydanticThinkingPart +from pydantic_ai.messages import ToolCallPart as PydanticToolCallPart +from pydantic_ai.messages import ToolReturnPart as PydanticToolReturnPart -def _make_event(event_type: str, payload, sequence: int) -> RunContextEvent: +def _make_event(part, sequence: int, turn_id: str | None = None) -> RunContextEvent: + payload = ContextPartChunkLog( + part=part, + sequence=sequence, + worker_binding_key="test-worker", + turn_id=turn_id, + ) return RunContextEvent( run_id=uuid4(), task_execution_id=uuid4(), worker_binding_key="test-worker", sequence=sequence, - event_type=event_type, + event_type=part.part_kind, payload=payload.model_dump(mode="json"), ) -def test_generation_part_kinds_have_context_event_counterparts() -> None: - assert ErgonTextPart(content="x").part_kind == "text" +def test_context_part_kinds_are_context_event_types() -> None: + assert AssistantTextPart(content="x").part_kind == "assistant_text" assert ErgonThinkingPart(content="x").part_kind == "thinking" - assert ErgonToolCallPart(tool_name="t", tool_call_id="1", args={}).part_kind == "tool-call" + assert ErgonToolCallPart(tool_name="t", tool_call_id="1", args={}).part_kind == "tool_call" assert ( - ErgonToolReturnPart(tool_call_id="1", tool_name="t", content="ok").part_kind - == "tool-return" + ErgonToolResultPart(tool_call_id="1", tool_name="t", content="ok").part_kind + == "tool_result" ) assert "assistant_text" in ContextEventType.__args__ @@ -73,13 +63,10 @@ def test_generation_part_kinds_have_context_event_counterparts() -> None: assert "tool_result" in ContextEventType.__args__ -def test_text_and_thinking_are_response_parts() -> None: - adapter: TranscriptAdapter[ - list[ModelRequest | ModelResponse], list[ModelRequest | ModelResponse] - ] +def test_text_and_thinking_are_context_part_chunks() -> None: adapter = PydanticAITranscriptAdapter() - turns = adapter.build_turns( + chunks = adapter.build_chunks( [ ModelRequest(parts=[UserPromptPart(content="hard question")]), ModelResponse( @@ -91,18 +78,20 @@ def test_text_and_thinking_are_response_parts() -> None: ] ) - assert len(turns) == 1 - turn = turns[0] - assert isinstance(turn, GenerationTurn) - assert any(isinstance(part, ErgonUserPromptPart) for part in turn.messages_in) - assert any(isinstance(part, ErgonThinkingPart) for part in turn.response_parts) - assert any(isinstance(part, ErgonTextPart) for part in turn.response_parts) + assert [chunk.part.part_kind for chunk in chunks] == [ + "user_message", + "thinking", + "assistant_text", + ] + assert isinstance(chunks[0].part, ErgonUserMessagePart) + assert isinstance(chunks[1].part, ErgonThinkingPart) + assert isinstance(chunks[2].part, AssistantTextPart) -def test_tool_return_is_attached_to_generating_turn() -> None: +def test_tool_call_and_return_become_context_part_chunks() -> None: adapter = PydanticAITranscriptAdapter() - turns = adapter.build_turns( + chunks = adapter.build_chunks( [ ModelRequest(parts=[UserPromptPart(content="search")]), ModelResponse( @@ -123,19 +112,17 @@ def test_tool_return_is_attached_to_generating_turn() -> None: ) ] ), - ModelResponse(parts=[TextPart(content="done")]), ] ) - assert len(turns) == 2 - first = turns[0] - assert any(isinstance(part, ErgonToolCallPart) for part in first.response_parts) - assert len(first.tool_results) == 1 - result = first.tool_results[0] - assert isinstance(result, ErgonToolReturnPart) - assert result.tool_call_id == "call-1" - assert result.tool_name == "search" - assert result.content == '{"result": "found"}' + assert [chunk.part.part_kind for chunk in chunks] == [ + "user_message", + "tool_call", + "tool_result", + ] + tool_result = chunks[-1].part + assert isinstance(tool_result, ErgonToolResultPart) + assert tool_result.content == '{"result": "found"}' def test_incremental_extraction_does_not_emit_pending_tool_call_response() -> None: @@ -154,14 +141,14 @@ def test_incremental_extraction_does_not_emit_pending_tool_call_response() -> No ), ] - assert adapter.build_new_turns(transcript, cursor, flush_pending=False) == [] + first = adapter.build_new_chunks(transcript, cursor, flush_pending=False) + assert [chunk.part.part_kind for chunk in first] == ["user_message"] - flushed = adapter.build_new_turns(transcript, cursor, flush_pending=True) - assert len(flushed) == 1 - assert any(isinstance(part, ErgonToolCallPart) for part in flushed[0].response_parts) + flushed = adapter.build_new_chunks(transcript, cursor, flush_pending=True) + assert [chunk.part.part_kind for chunk in flushed] == ["tool_call"] -def test_incremental_extraction_tracks_emitted_turns() -> None: +def test_incremental_extraction_tracks_emitted_chunks() -> None: adapter = PydanticAITranscriptAdapter() cursor = TranscriptTurnCursor() transcript = [ @@ -186,42 +173,36 @@ def test_incremental_extraction_tracks_emitted_turns() -> None: ), ] - first = adapter.build_new_turns(transcript, cursor, flush_pending=False) - second = adapter.build_new_turns(transcript, cursor, flush_pending=False) + first = adapter.build_new_chunks(transcript, cursor, flush_pending=False) + second = adapter.build_new_chunks(transcript, cursor, flush_pending=False) - assert len(first) == 1 + assert [chunk.part.part_kind for chunk in first] == [ + "user_message", + "tool_call", + "tool_result", + ] assert second == [] def test_assemble_replay_reconstructs_pydantic_ai_messages() -> None: events = [ - _make_event("system_prompt", SystemPromptPayload(text="sys"), 0), - _make_event("user_message", UserMessagePayload(text="use tool"), 1), + _make_event(ErgonSystemPromptPart(content="sys"), 0), + _make_event(ErgonUserMessagePart(content="use tool"), 1), _make_event( - "tool_call", - ToolCallPayload( + ErgonToolCallPart( tool_call_id="call-1", tool_name="my_tool", args={"x": 1}, - turn_id="t1", ), 2, + turn_id="t1", ), _make_event( - "tool_result", - ToolResultPayload(tool_call_id="call-1", tool_name="my_tool", result="42"), + ErgonToolResultPart(tool_call_id="call-1", tool_name="my_tool", content="42"), 3, ), - _make_event( - "thinking", - ThinkingPayload(text="considering", turn_id="t2"), - 4, - ), - _make_event( - "assistant_text", - AssistantTextPayload(text="The answer is 42.", turn_id="t2"), - 5, - ), + _make_event(ErgonThinkingPart(content="considering"), 4, turn_id="t2"), + _make_event(AssistantTextPart(content="The answer is 42."), 5, turn_id="t2"), ] messages = PydanticAITranscriptAdapter().assemble_replay(events) diff --git a/tests/unit/persistence/test_context_event_repository.py b/tests/unit/persistence/test_context_event_repository.py index d4aadeaf..c1321254 100644 --- a/tests/unit/persistence/test_context_event_repository.py +++ b/tests/unit/persistence/test_context_event_repository.py @@ -2,13 +2,15 @@ import pytest from ergon_core.core.generation import ( - GenerationTurn, - TextPart, + AssistantTextPart, + ContextPartChunk, + ContextPartChunkLog, ThinkingPart, ToolCallPart, - ToolReturnPart, - UserPromptPart, + ToolResultPart, + UserMessagePart, ) +from ergon_core.core.persistence.context.models import RunContextEvent from ergon_core.core.persistence.context.repository import ContextEventRepository from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphNode @@ -52,56 +54,94 @@ def _execution_fixture(session: Session) -> tuple: return run_id, execution.id +def test_run_context_event_parsed_payload_is_context_part_chunk_log() -> None: + log = ContextPartChunkLog( + part=AssistantTextPart(content="hello"), + sequence=3, + worker_binding_key="worker-a", + turn_id="turn-1", + ) + event = RunContextEvent( + run_id=uuid4(), + task_execution_id=uuid4(), + worker_binding_key="worker-a", + sequence=3, + event_type="assistant_text", + payload=log.model_dump(mode="json"), + ) + + parsed = event.parsed_payload() + + assert isinstance(parsed, ContextPartChunkLog) + assert parsed.part == AssistantTextPart(content="hello") + + @pytest.mark.asyncio -async def test_persist_turn_records_tool_results_from_tool_results() -> None: +async def test_persist_chunk_records_prompt_and_model_output_in_order() -> None: session = _session() run_id, execution_id = _execution_fixture(session) + repo = ContextEventRepository() - events = await ContextEventRepository().persist_turn( + await repo.persist_chunk( session, run_id=run_id, execution_id=execution_id, worker_binding_key="worker", - turn=GenerationTurn( - messages_in=[UserPromptPart(content="search")], - response_parts=[ - ToolCallPart(tool_name="search", tool_call_id="call-1", args={"query": "ergon"}) - ], - tool_results=[ - ToolReturnPart(tool_name="search", tool_call_id="call-1", content="found") - ], - ), + chunk=ContextPartChunk(part=UserMessagePart(content="question")), + ) + await repo.persist_chunk( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker", + chunk=ContextPartChunk(part=ThinkingPart(content="think")), + ) + await repo.persist_chunk( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker", + chunk=ContextPartChunk(part=AssistantTextPart(content="answer")), ) - assert [event.event_type for event in events] == ["user_message", "tool_call", "tool_result"] - tool_result = events[-1].parsed_payload() - assert tool_result.event_type == "tool_result" - assert tool_result.tool_name == "search" - assert tool_result.tool_call_id == "call-1" - assert tool_result.result == "found" + events = repo.get_for_execution(session, execution_id) + + assert [event.sequence for event in events] == [0, 1, 2] + assert [event.event_type for event in events] == [ + "user_message", + "thinking", + "assistant_text", + ] + assert events[1].parsed_payload().turn_id == events[2].parsed_payload().turn_id @pytest.mark.asyncio -async def test_persist_turn_records_thinking_before_assistant_text() -> None: +async def test_persist_chunk_tool_result_closes_current_turn() -> None: session = _session() run_id, execution_id = _execution_fixture(session) + repo = ContextEventRepository() - events = await ContextEventRepository().persist_turn( + await repo.persist_chunk( session, run_id=run_id, execution_id=execution_id, worker_binding_key="worker", - turn=GenerationTurn( - messages_in=[UserPromptPart(content="hard question")], - response_parts=[ - ThinkingPart(content="let me think"), - TextPart(content="answer"), - ], + chunk=ContextPartChunk( + part=ToolCallPart(tool_call_id="call-1", tool_name="search", args={"q": "x"}) + ), + ) + await repo.persist_chunk( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker", + chunk=ContextPartChunk( + part=ToolResultPart(tool_call_id="call-1", tool_name="search", content="ok") ), ) - assert [event.event_type for event in events] == [ - "user_message", - "thinking", - "assistant_text", - ] + events = repo.get_for_execution(session, execution_id) + + assert [event.event_type for event in events] == ["tool_call", "tool_result"] + assert events[0].parsed_payload().turn_id is not None + assert events[1].parsed_payload().turn_id is None diff --git a/tests/unit/runtime/test_context_event_contracts.py b/tests/unit/runtime/test_context_event_contracts.py index 884997ca..517068e7 100644 --- a/tests/unit/runtime/test_context_event_contracts.py +++ b/tests/unit/runtime/test_context_event_contracts.py @@ -2,11 +2,16 @@ from ergon_core.core.api.schemas import RunContextEventDto from ergon_core.core.dashboard.event_contracts import DashboardContextEventEvent -from ergon_core.core.persistence.context.event_payloads import AssistantTextPayload +from ergon_core.core.generation import AssistantTextPart, ContextPartChunkLog def test_rest_and_dashboard_context_events_share_typed_payload_shape() -> None: - payload = AssistantTextPayload(text="hello", turn_id="turn-1") + payload = ContextPartChunkLog( + part=AssistantTextPart(content="hello"), + sequence=1, + worker_binding_key="worker", + turn_id="turn-1", + ) common = { "id": uuid4(), "run_id": uuid4(), diff --git a/tests/unit/state/test_context_assembly.py b/tests/unit/state/test_context_assembly.py index a153a5dd..abf7b6e3 100644 --- a/tests/unit/state/test_context_assembly.py +++ b/tests/unit/state/test_context_assembly.py @@ -1,58 +1,43 @@ -"""State tests for context event assembly → PydanticAI message history. - -Tests the PydanticAITranscriptAdapter assemble_replay method using RunContextEvent -instances built directly (no DB round-trip needed for pure logic tests). -""" +"""State tests for context event assembly -> PydanticAI message history.""" from uuid import uuid4 from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter -from ergon_core.core.persistence.context.event_payloads import ( - AssistantTextPayload, - SystemPromptPayload, - ThinkingPayload, - ToolCallPayload, - ToolResultPayload, - UserMessagePayload, +from ergon_core.core.generation import ( + AssistantTextPart, + ContextPartChunkLog, + SystemPromptPart, + ThinkingPart, + ToolCallPart, + ToolResultPart, + UserMessagePart, ) from ergon_core.core.persistence.context.models import RunContextEvent -from pydantic_ai.messages import ( - ModelRequest, - ModelResponse, -) -from pydantic_ai.messages import ( - SystemPromptPart as PydanticSystemPromptPart, -) -from pydantic_ai.messages import ( - TextPart as PydanticTextPart, -) -from pydantic_ai.messages import ( - ThinkingPart as PydanticThinkingPart, -) -from pydantic_ai.messages import ( - ToolCallPart as PydanticToolCallPart, -) -from pydantic_ai.messages import ( - ToolReturnPart as PydanticToolReturnPart, -) -from pydantic_ai.messages import ( - UserPromptPart as PydanticUserPromptPart, -) +from pydantic_ai.messages import ModelRequest, ModelResponse +from pydantic_ai.messages import SystemPromptPart as PydanticSystemPromptPart +from pydantic_ai.messages import TextPart as PydanticTextPart +from pydantic_ai.messages import ThinkingPart as PydanticThinkingPart +from pydantic_ai.messages import ToolCallPart as PydanticToolCallPart +from pydantic_ai.messages import ToolReturnPart as PydanticToolReturnPart def assemble_pydantic_ai_messages(events: list[RunContextEvent]): return PydanticAITranscriptAdapter().assemble_replay(events) -def _make_event(event_type: str, payload, sequence: int) -> RunContextEvent: - run_id = uuid4() - exec_id = uuid4() +def _make_event(part, sequence: int, turn_id: str | None = None) -> RunContextEvent: + payload = ContextPartChunkLog( + part=part, + sequence=sequence, + worker_binding_key="test-worker", + turn_id=turn_id, + ) return RunContextEvent( - run_id=run_id, - task_execution_id=exec_id, + run_id=uuid4(), + task_execution_id=uuid4(), worker_binding_key="test-worker", sequence=sequence, - event_type=event_type, + event_type=part.part_kind, payload=payload.model_dump(mode="json"), ) @@ -60,13 +45,9 @@ def _make_event(event_type: str, payload, sequence: int) -> RunContextEvent: class TestAssembleSimpleConversation: def test_system_and_user_become_model_request(self): events = [ - _make_event("system_prompt", SystemPromptPayload(text="You are helpful."), 0), - _make_event("user_message", UserMessagePayload(text="Hello"), 1), - _make_event( - "assistant_text", - AssistantTextPayload(text="Hi!", turn_id="t1"), - 2, - ), + _make_event(SystemPromptPart(content="You are helpful."), 0), + _make_event(UserMessagePart(content="Hello"), 1), + _make_event(AssistantTextPart(content="Hi!"), 2, turn_id="t1"), ] messages = assemble_pydantic_ai_messages(events) @@ -93,66 +74,44 @@ def test_empty_events_returns_empty_list(self): class TestAssembleWithToolCall: def test_tool_call_in_response_and_tool_result_in_next_request(self): - tool_turn_id = "t1" events = [ - _make_event("system_prompt", SystemPromptPayload(text="sys"), 0), - _make_event("user_message", UserMessagePayload(text="use tool"), 1), + _make_event(SystemPromptPart(content="sys"), 0), + _make_event(UserMessagePart(content="use tool"), 1), _make_event( - "tool_call", - ToolCallPayload( + ToolCallPart( tool_call_id="call-1", tool_name="my_tool", args={"x": 1}, - turn_id=tool_turn_id, ), 2, + turn_id="t1", ), _make_event( - "tool_result", - ToolResultPayload( - tool_call_id="call-1", - tool_name="my_tool", - result="42", - ), + ToolResultPart(tool_call_id="call-1", tool_name="my_tool", content="42"), 3, ), - _make_event( - "assistant_text", - AssistantTextPayload(text="The answer is 42.", turn_id="t2"), - 4, - ), + _make_event(AssistantTextPart(content="The answer is 42."), 4, turn_id="t2"), ] messages = assemble_pydantic_ai_messages(events) - # 3 messages: initial request, tool-call response, tool-result+continuation request - # But the last assistant_text has no following tool_result, so it's a trailing response - # Expected structure: - # [0] ModelRequest(system_prompt, user_message) - # [1] ModelResponse(tool_call) - # [2] ModelRequest(tool_return) <- tool_result flushes response and opens request - # [3] ModelResponse(assistant_text) <- trailing response flushed at end assert len(messages) == 4 - assert isinstance(messages[0], ModelRequest) assert isinstance(messages[1], ModelResponse) assert isinstance(messages[2], ModelRequest) assert isinstance(messages[3], ModelResponse) - # Check tool call part tool_call_parts = [p for p in messages[1].parts if isinstance(p, PydanticToolCallPart)] assert len(tool_call_parts) == 1 assert tool_call_parts[0].tool_name == "my_tool" assert tool_call_parts[0].tool_call_id == "call-1" - # Check tool return part tool_return_parts = [p for p in messages[2].parts if isinstance(p, PydanticToolReturnPart)] assert len(tool_return_parts) == 1 assert tool_return_parts[0].tool_call_id == "call-1" assert tool_return_parts[0].tool_name == "my_tool" assert tool_return_parts[0].content == "42" - # Check final text response text_parts = [p for p in messages[3].parts if isinstance(p, PydanticTextPart)] assert len(text_parts) == 1 assert text_parts[0].content == "The answer is 42." @@ -161,17 +120,9 @@ def test_tool_call_in_response_and_tool_result_in_next_request(self): class TestAssembleWithThinking: def test_thinking_appears_in_model_response(self): events = [ - _make_event("user_message", UserMessagePayload(text="hard question"), 0), - _make_event( - "thinking", - ThinkingPayload(text="let me think...", turn_id="t1"), - 1, - ), - _make_event( - "assistant_text", - AssistantTextPayload(text="42", turn_id="t1"), - 2, - ), + _make_event(UserMessagePart(content="hard question"), 0), + _make_event(ThinkingPart(content="let me think..."), 1, turn_id="t1"), + _make_event(AssistantTextPart(content="42"), 2, turn_id="t1"), ] messages = assemble_pydantic_ai_messages(events) @@ -191,12 +142,8 @@ def test_thinking_appears_in_model_response(self): class TestAssembleTrailingResponse: def test_trailing_response_without_tool_result_is_flushed(self): events = [ - _make_event("user_message", UserMessagePayload(text="q"), 0), - _make_event( - "assistant_text", - AssistantTextPayload(text="a", turn_id="t1"), - 1, - ), + _make_event(UserMessagePart(content="q"), 0), + _make_event(AssistantTextPart(content="a"), 1, turn_id="t1"), ] messages = assemble_pydantic_ai_messages(events) @@ -207,11 +154,10 @@ def test_trailing_response_without_tool_result_is_flushed(self): def test_request_only_produces_no_assembled_messages(self): events = [ - _make_event("system_prompt", SystemPromptPayload(text="sys"), 0), - _make_event("user_message", UserMessagePayload(text="hi"), 1), + _make_event(SystemPromptPart(content="sys"), 0), + _make_event(UserMessagePart(content="hi"), 1), ] - # A request without a paired response event yields no assembled messages. messages = assemble_pydantic_ai_messages(events) assert messages == [] @@ -219,12 +165,8 @@ def test_request_only_produces_no_assembled_messages(self): class TestSystemPromptPartType: def test_system_prompt_is_pydantic_system_prompt_part(self): events = [ - _make_event("system_prompt", SystemPromptPayload(text="Be helpful."), 0), - _make_event( - "assistant_text", - AssistantTextPayload(text="ok", turn_id="t1"), - 1, - ), + _make_event(SystemPromptPart(content="Be helpful."), 0), + _make_event(AssistantTextPart(content="ok"), 1, turn_id="t1"), ] messages = assemble_pydantic_ai_messages(events) diff --git a/tests/unit/state/test_context_part_stream.py b/tests/unit/state/test_context_part_stream.py new file mode 100644 index 00000000..6f5390ca --- /dev/null +++ b/tests/unit/state/test_context_part_stream.py @@ -0,0 +1,64 @@ +from pydantic import TypeAdapter + +from ergon_core.core.generation import ( + AssistantTextPart, + ContextPart, + ContextPartChunk, + ContextPartChunkLog, + SystemPromptPart, + ThinkingPart, + TokenLogprob, + ToolCallPart, + ToolResultPart, + UserMessagePart, +) + + +def test_context_part_discriminates_all_part_kinds() -> None: + adapter = TypeAdapter(ContextPart) + + cases = [ + SystemPromptPart(content="sys"), + UserMessagePart(content="hi"), + AssistantTextPart(content="hello"), + ToolCallPart(tool_call_id="call-1", tool_name="search", args={"q": "x"}), + ToolResultPart(tool_call_id="call-1", tool_name="search", content="ok"), + ThinkingPart(content="reasoning"), + ] + + for part in cases: + dumped = part.model_dump(mode="json") + parsed = adapter.validate_python(dumped) + assert parsed == part + + +def test_context_part_chunk_wraps_part_with_optional_token_metadata() -> None: + chunk = ContextPartChunk( + part=AssistantTextPart(content="answer"), + token_ids=[1, 2], + logprobs=[TokenLogprob(token="answer", logprob=-0.1)], + ) + + dumped = chunk.model_dump(mode="json") + + assert dumped["part"]["part_kind"] == "assistant_text" + assert dumped["token_ids"] == [1, 2] + assert dumped["logprobs"][0]["token"] == "answer" + + +def test_context_part_chunk_log_adds_core_enrichment() -> None: + log = ContextPartChunkLog( + part=ThinkingPart(content="hmm"), + sequence=7, + worker_binding_key="researcher", + turn_id="turn-1", + token_ids=None, + logprobs=None, + ) + + dumped = log.model_dump(mode="json") + + assert dumped["part"]["part_kind"] == "thinking" + assert dumped["sequence"] == 7 + assert dumped["worker_binding_key"] == "researcher" + assert dumped["turn_id"] == "turn-1" diff --git a/tests/unit/state/test_generation_turn_build.py b/tests/unit/state/test_generation_turn_build.py deleted file mode 100644 index c7ea1f0a..00000000 --- a/tests/unit/state/test_generation_turn_build.py +++ /dev/null @@ -1,83 +0,0 @@ -# tests/state/test_generation_turn_build.py -"""Tests for building GenerationTurn values from PydanticAI transcripts.""" - -from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter -from ergon_core.core.generation import ( - GenerationTurn, - SystemPromptPart as ErgonSystemPromptPart, - TextPart as ErgonTextPart, - ToolCallPart as ErgonToolCallPart, - ToolReturnPart as ErgonToolReturnPart, - UserPromptPart as ErgonUserPromptPart, -) -from pydantic_ai.messages import ( - ModelRequest, - ModelResponse, - SystemPromptPart, - TextPart, - ToolCallPart, - ToolReturnPart, - UserPromptPart, -) - - -def _build_turns(messages): - return PydanticAITranscriptAdapter().build_turns(messages) - - -def _make_messages_text_only(): - """One request → one text response (no tools).""" - return [ - ModelRequest( - parts=[ - SystemPromptPart(content="You are helpful."), - UserPromptPart(content="Hello"), - ] - ), - ModelResponse(parts=[TextPart(content="Hi there!")]), - ] - - -def _make_messages_with_tool_call(): - """Request → tool-call response → tool-return request → text response.""" - return [ - ModelRequest(parts=[UserPromptPart(content="Search Paris.")]), - ModelResponse( - parts=[ToolCallPart(tool_name="search", tool_call_id="c1", args={"q": "Paris"})] - ), - ModelRequest( - parts=[ToolReturnPart(tool_call_id="c1", tool_name="search", content="pop 2M")] - ), - ModelResponse(parts=[TextPart(content="Paris has 2M people.")]), - ] - - -class TestBuildTurns: - def test_text_only_produces_one_turn(self): - turns = _build_turns(_make_messages_text_only()) - assert len(turns) == 1 - t = turns[0] - assert isinstance(t, GenerationTurn) - assert any(isinstance(p, ErgonSystemPromptPart) for p in t.messages_in) - assert any(isinstance(p, ErgonUserPromptPart) for p in t.messages_in) - assert any(isinstance(p, ErgonTextPart) for p in t.response_parts) - assert t.tool_results == [] - - def test_tool_call_has_tool_results(self): - turns = _build_turns(_make_messages_with_tool_call()) - assert len(turns) == 2 - first = turns[0] - assert len(first.tool_results) == 1 - tr = first.tool_results[0] - assert isinstance(tr, ErgonToolReturnPart) - assert tr.tool_call_id == "c1" - assert tr.content == "pop 2M" - - def test_tool_results_not_in_second_turn_messages_in(self): - """ToolReturnParts must NOT appear in messages_in — they're in tool_results.""" - turns = _build_turns(_make_messages_with_tool_call()) - second = turns[1] - tool_return_in_messages = [ - p for p in second.messages_in if isinstance(p, ErgonToolReturnPart) - ] - assert tool_return_in_messages == [] diff --git a/tests/unit/state/test_research_rubrics_workers.py b/tests/unit/state/test_research_rubrics_workers.py index dcb03a78..688ab349 100644 --- a/tests/unit/state/test_research_rubrics_workers.py +++ b/tests/unit/state/test_research_rubrics_workers.py @@ -25,7 +25,7 @@ _WORKFLOW_PROMPT, ResearchRubricsWorkflowCliReActWorker, ) -from ergon_core.core.generation import GenerationTurn +from ergon_core.core.generation import ContextPartChunk from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext @@ -230,7 +230,7 @@ async def test_report_read_uses_manager_public_file_api(self): # --------------------------------------------------------------------------- -async def _empty_gen() -> AsyncGenerator[GenerationTurn, None]: +async def _empty_gen() -> AsyncGenerator[ContextPartChunk, None]: return yield # type: ignore[misc] # makes this a generator diff --git a/tests/unit/workers/test_react_worker_contract.py b/tests/unit/workers/test_react_worker_contract.py index 064df348..ee954891 100644 --- a/tests/unit/workers/test_react_worker_contract.py +++ b/tests/unit/workers/test_react_worker_contract.py @@ -168,7 +168,7 @@ def _minimal_context() -> WorkerContext: @pytest.mark.asyncio -async def test_react_worker_yields_partial_turn_before_reraising_agent_iter_failure( +async def test_react_worker_yields_partial_chunk_before_reraising_agent_iter_failure( monkeypatch, ) -> None: monkeypatch.setattr(react_worker_module, "Agent", _FailingAgent) @@ -192,13 +192,13 @@ async def test_react_worker_yields_partial_turn_before_reraising_agent_iter_fail max_iterations=10, ) - turns = [] + chunks = [] with pytest.raises(RuntimeError, match="tool validation failed"): - async for turn in worker.execute(_minimal_task(), context=_minimal_context()): - turns.append(turn) + async for chunk in worker.execute(_minimal_task(), context=_minimal_context()): + chunks.append(chunk) - assert len(turns) == 1 - assert any(part.content == "partial answer" for part in turns[0].response_parts) + assert [chunk.part.part_kind for chunk in chunks] == ["user_message", "assistant_text"] + assert chunks[-1].part.content == "partial answer" @pytest.mark.asyncio @@ -226,8 +226,8 @@ async def test_react_worker_passes_agent_deps_to_pydantic_ai(monkeypatch) -> Non max_iterations=10, ) - turns = [turn async for turn in worker.execute(_minimal_task(), context=_minimal_context())] + chunks = [chunk async for chunk in worker.execute(_minimal_task(), context=_minimal_context())] - assert len(turns) == 1 + assert [chunk.part.part_kind for chunk in chunks] == ["user_message", "assistant_text"] assert _DepsAgent.init_kwargs["deps_type"] is dict assert _DepsAgent.iter_kwargs["deps"] == {"execution_id": str(UUID(int=5))} From 187019e36a829ccd60abb1911dbcee574ddde3fb Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:45:09 +0100 Subject: [PATCH 35/66] docs: capture runtime cleanup plans Record the real-LLM debugging, context stream, and runtime layout cleanup notes that explain the branch direction. Made-with: Cursor --- docs/architecture/03_providers.md | 12 +- docs/architecture/cross_cutting/artifacts.md | 2 +- ...-cleanup-cancelled-task-release-sandbox.md | 8 +- ...-04-17-sandbox-lifetime-covers-criteria.md | 86 +- .../2026-04-18-sandbox-manager-key-cleanup.md | 6 +- ...026-04-18-sandbox-manager-process-state.md | 22 +- .../02-test-brittleness-and-gaps.md | 2 +- .../03-code-quality.md | 2 +- .../final-worker-output-source-of-truth.md | 177 +++ ...27-react-worker-failure-context-capture.md | 650 ++++++++ .../2026-04-28-agent-tool-budget-harness.md | 811 ++++++++++ .../2026-04-28-context-part-chunk-stream.md | 1359 +++++++++++++++++ ...evaluation-resource-context-and-scoring.md | 909 +++++++++++ ergon_builtins/AGENTS.md | 6 +- 14 files changed, 3978 insertions(+), 74 deletions(-) create mode 100644 docs/rfcs/active/final-worker-output-source-of-truth.md create mode 100644 docs/superpowers/plans/2026-04-27-react-worker-failure-context-capture.md create mode 100644 docs/superpowers/plans/2026-04-28-agent-tool-budget-harness.md create mode 100644 docs/superpowers/plans/2026-04-28-context-part-chunk-stream.md create mode 100644 docs/superpowers/plans/2026-04-28-evaluation-resource-context-and-scoring.md diff --git a/docs/architecture/03_providers.md b/docs/architecture/03_providers.md index 7a957547..7b1d900e 100644 --- a/docs/architecture/03_providers.md +++ b/docs/architecture/03_providers.md @@ -2,7 +2,7 @@ ## 1. Purpose -The providers layer is Ergon's boundary between runtime code and external execution substrates. It owns four concerns: resolving `model_id` strings to `pydantic_ai.models.Model` instances, provisioning and tearing down E2B sandboxes via per-benchmark manager subclasses, surfacing sandbox state transitions as dashboard events, and publishing worker outputs as content-addressed blobs that evaluators can re-read. Everything that crosses the process boundary (LLM API, container runtime, blob storage) is routed through this layer so the runtime, workers, and evaluators stay substrate-agnostic. +The provider-style boundaries are Ergon's adapters between runtime code and external execution substrates. Model resolution lives in the generation registry, while sandbox infrastructure now lives under `ergon_core.core.sandbox` because it owns lifecycle, instrumentation, event emission, and artifact publishing rather than just a third-party provider adapter. ## 2. Core abstractions @@ -11,12 +11,12 @@ The providers layer is Ergon's boundary between runtime code and external execut | `_BACKEND_REGISTRY` | module-level dict | `ergon_core/core/providers/generation/model_resolution.py` | Frozen shape; entries grow via registration. | Providers layer. | | `resolve_model_target` | function | `ergon_core/core/providers/generation/model_resolution.py` | Public, frozen signature. Returns `ResolvedModel`. | Providers layer. | | `register_model_backend` | function | `ergon_core/core/providers/generation/model_resolution.py` | Public, frozen signature. | Providers layer; callers are backend modules executing at import time. | -| `BaseSandboxManager` | abstract class + singleton | `ergon_core/core/providers/sandbox/manager.py` | Shape stable; `event_sink` activation path in flux. | Providers layer. | -| `DefaultSandboxManager` | concrete class | `ergon_core/core/providers/sandbox/manager.py` | Frozen. | Providers layer. | +| `BaseSandboxManager` | abstract class + singleton | `ergon_core/core/sandbox/manager.py` | Shape stable; `event_sink` activation path in flux. | Sandbox domain. | +| `DefaultSandboxManager` | concrete class | `ergon_core/core/sandbox/manager.py` | Frozen. | Sandbox domain. | | `SWEBenchSandboxManager`, `MiniF2FSandboxManager`, `ResearchRubricsSandboxManager` | concrete subclasses | `ergon_builtins/` | Owned per benchmark; singletons. | Benchmark authors. | -| `SandboxEventSink` | `typing.Protocol` | `ergon_core/core/providers/sandbox/event_sink.py` | Frozen protocol; activation path in flux. | Providers layer. | -| `NoopSandboxEventSink`, `DashboardEmitterSandboxEventSink` | implementations | `ergon_core/core/providers/sandbox/event_sink.py` | Frozen. | Providers layer. | -| `SandboxResourcePublisher` | class | `ergon_core/core/providers/sandbox/resource_publisher.py` | Frozen API; storage backend swappable via `ERGON_BLOB_ROOT`. | Providers layer. | +| `SandboxEventSink` | `typing.Protocol` | `ergon_core/core/sandbox/event_sink.py` | Frozen protocol; activation path in flux. | Sandbox domain. | +| `NoopSandboxEventSink`, `DashboardEmitterSandboxEventSink` | implementations | `ergon_core/core/sandbox/event_sink.py` | Frozen. | Sandbox domain. | +| `SandboxResourcePublisher` | class | `ergon_core/core/sandbox/resource_publisher.py` | Frozen API; storage backend swappable via `ERGON_BLOB_ROOT`. | Sandbox domain. | | `TransformersModel` | `pydantic_ai.models.Model` subclass | `ergon_builtins/ergon_builtins/models/transformers_backend.py` | Frozen. | ML team (TRL training loop callers). | ### 2.1 Generation registry diff --git a/docs/architecture/cross_cutting/artifacts.md b/docs/architecture/cross_cutting/artifacts.md index bc6b5fe9..04506b02 100644 --- a/docs/architecture/cross_cutting/artifacts.md +++ b/docs/architecture/cross_cutting/artifacts.md @@ -15,7 +15,7 @@ produces computed artifacts through `CriterionRuntime.run_command(...)`. | Type | Location | Freeze | Owner | |------|----------|--------|-------| -| `SandboxResourcePublisher` | `ergon_core/core/providers/sandbox/resource_publisher.py` | Stable | Sandbox provider | +| `SandboxResourcePublisher` | `ergon_core/core/sandbox/resource_publisher.py` | Stable | Sandbox domain | | `RunResource` | ORM row; table `run_resources` | Stable wire shape | Persistence layer | | `dashboard/resource.published` | Inngest event | Stable | Dashboard lane | | `CriterionRuntime.read_resource(name)` | Proposed per RFC | Pending | Evaluator layer | diff --git a/docs/rfcs/active/2026-04-17-cleanup-cancelled-task-release-sandbox.md b/docs/rfcs/active/2026-04-17-cleanup-cancelled-task-release-sandbox.md index 22b7426d..a207dbe5 100644 --- a/docs/rfcs/active/2026-04-17-cleanup-cancelled-task-release-sandbox.md +++ b/docs/rfcs/active/2026-04-17-cleanup-cancelled-task-release-sandbox.md @@ -51,7 +51,7 @@ to a `type[BaseSandboxManager]` (not an instance). The cleanup function would need to resolve the class and call the static method `BaseSandboxManager.terminate_by_sandbox_id(sandbox_id)`. `terminate_by_sandbox_id` is a `@staticmethod` at -`ergon_core/ergon_core/core/providers/sandbox/manager.py:472-490` that calls +`ergon_core/ergon_core/core/sandbox/manager.py:472-490` that calls `AsyncSandbox.kill(sandbox_id=..., api_key=...)` directly via E2B, so no instance is needed. However, `cleanup_cancelled_task_fn` currently has no import path to `SANDBOX_MANAGERS`. @@ -278,7 +278,7 @@ import logging import inngest from ergon_builtins.registry import SANDBOX_MANAGERS -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager from ergon_core.core.runtime.events.task_events import TaskCancelledEvent from ergon_core.core.runtime.inngest_client import RUN_CANCEL, inngest_client from ergon_core.core.runtime.services.task_cleanup_dto import CleanupResult @@ -712,13 +712,13 @@ class TestReleaseSandboxStep: async def test_releases_sandbox_when_fields_present(self) -> None: """terminate_by_sandbox_id called exactly once for valid payload.""" with patch( - "ergon_core.core.providers.sandbox.manager.BaseSandboxManager" + "ergon_core.core.sandbox.manager.BaseSandboxManager" ".terminate_by_sandbox_id", new_callable=AsyncMock, return_value=True, ) as mock_terminate: from ergon_builtins.registry import SANDBOX_MANAGERS - from ergon_core.core.providers.sandbox.manager import BaseSandboxManager + from ergon_core.core.sandbox.manager import BaseSandboxManager # Any known slug from SANDBOX_MANAGERS slug = next(iter(SANDBOX_MANAGERS)) diff --git a/docs/rfcs/active/2026-04-17-sandbox-lifetime-covers-criteria.md b/docs/rfcs/active/2026-04-17-sandbox-lifetime-covers-criteria.md index 5b6c0bda..e54a8f9e 100644 --- a/docs/rfcs/active/2026-04-17-sandbox-lifetime-covers-criteria.md +++ b/docs/rfcs/active/2026-04-17-sandbox-lifetime-covers-criteria.md @@ -14,7 +14,7 @@ superseded_by: null ### Current state `BaseSandboxManager.create()` at -`ergon_core/ergon_core/core/providers/sandbox/manager.py:226` accepts a single +`ergon_core/ergon_core/core/sandbox/manager.py:226` accepts a single `timeout_minutes: int = 30` parameter. Every call site passes a literal or relies on the default: @@ -145,7 +145,7 @@ reconnect path; `CriterionRuntime.ensure_sandbox()` will call it once RFC **Change 3 — Define `SandboxExpiredError`.** New exception class at -`ergon_core/ergon_core/core/providers/sandbox/errors.py`. Subclasses the base +`ergon_core/ergon_core/core/sandbox/errors.py`. Subclasses the base `Exception` (not `ErgonNonRetriableError` — sandbox expiry is not a definition-level error; it is a transient infrastructure condition). Criteria that catch it should surface a `"sandbox-expired"` evaluation outcome rather @@ -225,7 +225,7 @@ SandboxSetupRequest (payload) ## Type / interface definitions ```python -# ergon_core/ergon_core/core/providers/sandbox/errors.py +# ergon_core/ergon_core/core/sandbox/errors.py """Sandbox-specific exception types.""" @@ -260,7 +260,7 @@ class SandboxExpiredError(SandboxError): ### `errors.py` (new file) ```python -# ergon_core/ergon_core/core/providers/sandbox/errors.py +# ergon_core/ergon_core/core/sandbox/errors.py """Sandbox-specific exception types.""" @@ -291,7 +291,7 @@ class SandboxExpiredError(SandboxError): ### `reconnect` method (added to `BaseSandboxManager`) ```python -# Added to: ergon_core/ergon_core/core/providers/sandbox/manager.py +# Added to: ergon_core/ergon_core/core/sandbox/manager.py # Location: after get_sandbox() at line 394, before get_sandbox_path() async def reconnect(self, sandbox_id: str) -> "AsyncSandbox": @@ -308,7 +308,7 @@ async def reconnect(self, sandbox_id: str) -> "AsyncSandbox": This method does NOT register the sandbox in class-level state; callers should not assume it shows up in _sandboxes. """ - from ergon_core.core.providers.sandbox.errors import SandboxExpiredError + from ergon_core.core.sandbox.errors import SandboxExpiredError if AsyncSandbox is None: raise RuntimeError( @@ -331,7 +331,7 @@ async def reconnect(self, sandbox_id: str) -> "AsyncSandbox": ### Updated `create()` signature — `BaseSandboxManager` ```python -# ergon_core/ergon_core/core/providers/sandbox/manager.py +# ergon_core/ergon_core/core/sandbox/manager.py # Replace lines 226-295 (existing create method) async def create( @@ -423,7 +423,7 @@ async def create( ### Updated `DefaultSandboxManager.create()` override ```python -# ergon_core/ergon_core/core/providers/sandbox/manager.py +# ergon_core/ergon_core/core/sandbox/manager.py # Replace lines 503-526 (existing DefaultSandboxManager.create override) async def create( @@ -457,21 +457,21 @@ async def create( ### Updated `__init__.py` (sandbox package) ```python -# ergon_core/ergon_core/core/providers/sandbox/__init__.py +# ergon_core/ergon_core/core/sandbox/__init__.py # Add SandboxExpiredError, SandboxError to exports """Sandbox management: provisioning, file I/O, lifecycle.""" -from ergon_core.core.providers.sandbox.errors import ( +from ergon_core.core.sandbox.errors import ( SandboxError, SandboxExpiredError, ) -from ergon_core.core.providers.sandbox.event_sink import ( +from ergon_core.core.sandbox.event_sink import ( DashboardEmitterSandboxEventSink, NoopSandboxEventSink, SandboxEventSink, ) -from ergon_core.core.providers.sandbox.manager import ( +from ergon_core.core.sandbox.manager import ( BaseSandboxManager, DefaultSandboxManager, DownloadedFile, @@ -495,7 +495,7 @@ __all__ = [ ## Exact diffs for modified files -### `ergon_core/ergon_core/core/providers/sandbox/manager.py` +### `ergon_core/ergon_core/core/sandbox/manager.py` ```diff @@ -226,13 +226,16 @@ class BaseSandboxManager(ABC): @@ -559,7 +559,7 @@ __all__ = [ + sandbox is not found or has already timed out. Idempotent. + Does NOT register in class-level _sandboxes state. + """ -+ from ergon_core.core.providers.sandbox.errors import SandboxExpiredError ++ from ergon_core.core.sandbox.errors import SandboxExpiredError + + if AsyncSandbox is None: + raise RuntimeError( @@ -640,22 +640,22 @@ __all__ = [ Note: `reset_timeout` call changes from 30 to 40 to match the new provisioned total. The signature of `reset_timeout` at `manager.py:407` is unchanged (still accepts `timeout_minutes`). -### `ergon_core/ergon_core/core/providers/sandbox/__init__.py` +### `ergon_core/ergon_core/core/sandbox/__init__.py` ```diff @@ -1,6 +1,11 @@ """Sandbox management: provisioning, file I/O, lifecycle.""" -+from ergon_core.core.providers.sandbox.errors import ( ++from ergon_core.core.sandbox.errors import ( + SandboxError, + SandboxExpiredError, +) - from ergon_core.core.providers.sandbox.event_sink import ( + from ergon_core.core.sandbox.event_sink import ( DashboardEmitterSandboxEventSink, NoopSandboxEventSink, SandboxEventSink, ) - from ergon_core.core.providers.sandbox.manager import ( + from ergon_core.core.sandbox.manager import ( BaseSandboxManager, DefaultSandboxManager, DownloadedFile, @@ -683,7 +683,7 @@ New file, no new package. The errors module sits alongside the existing sandbox package files: ``` -ergon_core/ergon_core/core/providers/sandbox/ +ergon_core/ergon_core/core/sandbox/ ├── __init__.py MODIFY (add SandboxError, SandboxExpiredError exports) ├── errors.py ADD (SandboxError, SandboxExpiredError) ├── event_sink.py no change @@ -700,15 +700,15 @@ ergon_core/ergon_core/core/providers/sandbox/ | Step | Phase | What | Files touched | |------|-------|------|---------------| -| 1 | PR 1 | Create `errors.py` with `SandboxError` and `SandboxExpiredError` | ADD `ergon_core/ergon_core/core/providers/sandbox/errors.py` | -| 2 | PR 1 | Add `errors` imports to sandbox `__init__.py` | MODIFY `ergon_core/ergon_core/core/providers/sandbox/__init__.py` | -| 3 | PR 1 | Update `BaseSandboxManager.create()` signature: `timeout_minutes` → `task_timeout_minutes + max_criterion_timeout_minutes`; update WAL entry log | MODIFY `ergon_core/ergon_core/core/providers/sandbox/manager.py` | -| 4 | PR 1 | Update `DefaultSandboxManager.create()` override with same signature change | MODIFY `ergon_core/ergon_core/core/providers/sandbox/manager.py` | +| 1 | PR 1 | Create `errors.py` with `SandboxError` and `SandboxExpiredError` | ADD `ergon_core/ergon_core/core/sandbox/errors.py` | +| 2 | PR 1 | Add `errors` imports to sandbox `__init__.py` | MODIFY `ergon_core/ergon_core/core/sandbox/__init__.py` | +| 3 | PR 1 | Update `BaseSandboxManager.create()` signature: `timeout_minutes` → `task_timeout_minutes + max_criterion_timeout_minutes`; update WAL entry log | MODIFY `ergon_core/ergon_core/core/sandbox/manager.py` | +| 4 | PR 1 | Update `DefaultSandboxManager.create()` override with same signature change | MODIFY `ergon_core/ergon_core/core/sandbox/manager.py` | | 5 | PR 1 | Migrate `sandbox_setup.py` call site: `timeout_minutes=30` → `task_timeout_minutes=30` | MODIFY `ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py` | | 6 | PR 1 | Migrate `criterion_runtime.py` call sites: same rename; `reset_timeout` 30 → 40 | MODIFY `ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py` | | 7 | PR 1 | Migrate test call sites: `timeout_minutes=5` → `task_timeout_minutes=5` in `tests/swebench_verified/test_sandbox_manager.py` and `tests/minif2f/test_sandbox_manager.py` | MODIFY 2 test files | | 8 | PR 1 | Unit tests: `create()` passes correct total timeout to E2B; `task_timeout + max_criterion_timeout` arithmetic | ADD `tests/unit/test_sandbox_timeout.py` | -| 9 | PR 2 | Add `BaseSandboxManager.reconnect(sandbox_id)` method | MODIFY `ergon_core/ergon_core/core/providers/sandbox/manager.py` | +| 9 | PR 2 | Add `BaseSandboxManager.reconnect(sandbox_id)` method | MODIFY `ergon_core/ergon_core/core/sandbox/manager.py` | | 10 | PR 2 | Unit tests for `reconnect`: successful connect, E2B-not-found raises `SandboxExpiredError`, non-expired E2B error re-raises | ADD to `tests/unit/test_sandbox_reconnect.py` | | 11 | PR 2 | Canary e2e test: deliberately-slow criterion (sleep > task_timeout) still finds sandbox reachable | ADD `tests/e2e/test_sandbox_criterion_timeout_canary.py` | | 12 | PR 2 | (Deferred — depends on `2026-04-17-criterion-runtime-di-container`) Migrate `DefaultCriterionRuntime.ensure_sandbox()` to use `reconnect` when `get_sandbox` returns `None`, handling `SandboxExpiredError` | MODIFY `criterion_runtime.py` | @@ -724,7 +724,7 @@ Steps 1–8 land as PR 1 ("sandbox-lifetime/split-timeout"). Steps 9–11 land a | File | Purpose | |------|---------| -| `ergon_core/ergon_core/core/providers/sandbox/errors.py` | `SandboxError` base class; `SandboxExpiredError` raised by `reconnect()` on expired sandbox | +| `ergon_core/ergon_core/core/sandbox/errors.py` | `SandboxError` base class; `SandboxExpiredError` raised by `reconnect()` on expired sandbox | | `tests/unit/test_sandbox_timeout.py` | Unit tests: `create()` arithmetic, `task_timeout + max_criterion_timeout` passed to E2B | | `tests/unit/test_sandbox_reconnect.py` | Unit tests: `reconnect()` success, not-found raises `SandboxExpiredError`, other errors re-raise | | `tests/e2e/test_sandbox_criterion_timeout_canary.py` | E2e canary: slow criterion still reaches sandbox when timeout is correctly provisioned | @@ -733,8 +733,8 @@ Steps 1–8 land as PR 1 ("sandbox-lifetime/split-timeout"). Steps 9–11 land a | File | Changes | |------|---------| -| `ergon_core/ergon_core/core/providers/sandbox/manager.py` | Split `timeout_minutes` into `task_timeout_minutes + max_criterion_timeout_minutes` in `BaseSandboxManager.create()` and `DefaultSandboxManager.create()`; add `reconnect()` method | -| `ergon_core/ergon_core/core/providers/sandbox/__init__.py` | Export `SandboxError`, `SandboxExpiredError` | +| `ergon_core/ergon_core/core/sandbox/manager.py` | Split `timeout_minutes` into `task_timeout_minutes + max_criterion_timeout_minutes` in `BaseSandboxManager.create()` and `DefaultSandboxManager.create()`; add `reconnect()` method | +| `ergon_core/ergon_core/core/sandbox/__init__.py` | Export `SandboxError`, `SandboxExpiredError` | | `ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py` | Rename `timeout_minutes=30` → `task_timeout_minutes=30` at line 106 | | `ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py` | Rename `timeout_minutes=30` → `task_timeout_minutes=30` at line 59; `reset_timeout(..., timeout_minutes=30)` → `timeout_minutes=40` at line 63 | | `tests/swebench_verified/test_sandbox_manager.py` | Rename `timeout_minutes=5` → `task_timeout_minutes=5`; update assertion `call_kwargs["timeout"] == 5 * 60` → `== (5 + 10) * 60` | @@ -758,7 +758,7 @@ from uuid import uuid4 import pytest -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager, DefaultSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager, DefaultSandboxManager @pytest.fixture(autouse=True) @@ -792,11 +792,11 @@ async def test_create_passes_total_timeout_to_e2b(monkeypatch: pytest.MonkeyPatc fake_sandbox.sandbox_id = "sbx-test" fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -819,11 +819,11 @@ async def test_create_default_max_criterion_timeout(monkeypatch: pytest.MonkeyPa fake_sandbox.sandbox_id = "sbx-default" fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -841,11 +841,11 @@ async def test_create_zero_criterion_timeout(monkeypatch: pytest.MonkeyPatch) -> fake_sandbox.sandbox_id = "sbx-zero" fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -875,8 +875,8 @@ from uuid import uuid4 import pytest -from ergon_core.core.providers.sandbox.errors import SandboxExpiredError -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.errors import SandboxExpiredError +from ergon_core.core.sandbox.manager import BaseSandboxManager @pytest.fixture(autouse=True) @@ -902,11 +902,11 @@ async def test_reconnect_returns_sandbox_on_success(monkeypatch: pytest.MonkeyPa fake_sandbox = MagicMock() fake_connect = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -924,11 +924,11 @@ async def test_reconnect_raises_sandbox_expired_on_not_found( """reconnect() raises SandboxExpiredError when E2B returns 'not found'.""" fake_connect = AsyncMock(side_effect=Exception("sandbox not found (404)")) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -945,11 +945,11 @@ async def test_reconnect_reraises_non_expiry_errors(monkeypatch: pytest.MonkeyPa """reconnect() re-raises unexpected E2B errors unchanged.""" fake_connect = AsyncMock(side_effect=ConnectionError("network blip")) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -978,7 +978,7 @@ import asyncio import pytest from uuid import uuid4 -from ergon_core.core.providers.sandbox.manager import DefaultSandboxManager, BaseSandboxManager +from ergon_core.core.sandbox.manager import DefaultSandboxManager, BaseSandboxManager @pytest.fixture(autouse=True) diff --git a/docs/rfcs/active/2026-04-18-sandbox-manager-key-cleanup.md b/docs/rfcs/active/2026-04-18-sandbox-manager-key-cleanup.md index d694a25e..e0452646 100644 --- a/docs/rfcs/active/2026-04-18-sandbox-manager-key-cleanup.md +++ b/docs/rfcs/active/2026-04-18-sandbox-manager-key-cleanup.md @@ -25,7 +25,7 @@ reduces the diff size for that RFC. ## Problem `BaseSandboxManager.create()` -(`ergon_core/ergon_core/core/providers/sandbox/manager.py:226-233`) takes three +(`ergon_core/ergon_core/core/sandbox/manager.py:226-233`) takes three conceptual task-keys as positional/keyword arguments: ```python @@ -177,7 +177,7 @@ production cases — which is exactly what `task_id` is after the rename. ## Full implementation -### Modified file: `ergon_core/ergon_core/core/providers/sandbox/manager.py` +### Modified file: `ergon_core/ergon_core/core/sandbox/manager.py` #### 1. Remove `_display_task_ids` class attribute @@ -575,7 +575,7 @@ None. | File | Changes | |---|---| -| `ergon_core/ergon_core/core/providers/sandbox/manager.py` | Delete `_display_task_ids` attr (line 70); delete `_get_display_task_id()` (lines 96-97); rename `sandbox_key`→`task_id` + remove `display_task_id` in `BaseSandboxManager.create()` (lines 226-295); rename `sandbox_key`→`task_id` + rename `task_id`→`override_task_id` in `_emit_wal_entry()` (lines 99-131); simplify `terminate()` (lines 429-469); rename + remove `display_task_id` in `DefaultSandboxManager.create()` (lines 503-526) | +| `ergon_core/ergon_core/core/sandbox/manager.py` | Delete `_display_task_ids` attr (line 70); delete `_get_display_task_id()` (lines 96-97); rename `sandbox_key`→`task_id` + remove `display_task_id` in `BaseSandboxManager.create()` (lines 226-295); rename `sandbox_key`→`task_id` + rename `task_id`→`override_task_id` in `_emit_wal_entry()` (lines 99-131); simplify `terminate()` (lines 429-469); rename + remove `display_task_id` in `DefaultSandboxManager.create()` (lines 503-526) | | `ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py` | Drop `display_task_id=task_id` kwarg at line 108 | | `ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py` | Rename `sandbox_key=` → `task_id=` at line 74 | | `tests/minif2f/test_sandbox_manager.py` | Remove `BaseSandboxManager._display_task_ids = {}` at line 30; rename `sandbox_key=` → `task_id=` at lines 121, 172, 206 | diff --git a/docs/rfcs/active/2026-04-18-sandbox-manager-process-state.md b/docs/rfcs/active/2026-04-18-sandbox-manager-process-state.md index 4e047349..0d82db05 100644 --- a/docs/rfcs/active/2026-04-18-sandbox-manager-process-state.md +++ b/docs/rfcs/active/2026-04-18-sandbox-manager-process-state.md @@ -14,7 +14,7 @@ superseded_by: null ## 1. Problem `BaseSandboxManager` -(`ergon_core/ergon_core/core/providers/sandbox/manager.py`) is wired as a +(`ergon_core/ergon_core/core/sandbox/manager.py`) is wired as a singleton-per-subclass via `__new__` at `manager.py:78-81`: ```python @@ -71,7 +71,7 @@ The same pattern appears in: - `ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py:72` `ResearchRubricsSandboxManager` (in -`ergon_core/ergon_core/core/providers/sandbox/research_rubrics_manager.py`) also +`ergon_builtins/ergon_builtins/benchmarks/researchrubrics/sandbox_manager.py`) also calls `self._sandboxes[task_id]` directly at `research_rubrics_manager.py:105` in `publisher_for()`, relying on the class-level dict. @@ -237,7 +237,7 @@ DefaultCriterionRuntime.ensure_sandbox() (any process) ### 4.1 Updated `BaseSandboxManager.__init__` ```python -# ergon_core/ergon_core/core/providers/sandbox/manager.py +# ergon_core/ergon_core/core/sandbox/manager.py class BaseSandboxManager(ABC): """Abstract base class for E2B sandbox lifecycle management. @@ -267,7 +267,7 @@ class BaseSandboxManager(ABC): ### 4.2 `reconnect` method signature ```python -# ergon_core/ergon_core/core/providers/sandbox/manager.py +# ergon_core/ergon_core/core/sandbox/manager.py async def reconnect(self, sandbox_id: str) -> "AsyncSandbox": """Rehydrate a running sandbox by its E2B sandbox_id. @@ -538,7 +538,7 @@ Behavior unchanged. Stage 1 is a pure refactor. | File | Changes | |---|---| -| `ergon/ergon_core/ergon_core/core/providers/sandbox/manager.py` | Stage 1: move six dicts to `__init__`, fix `_event_sink` init; Stage 2: remove `__new__` + `_instance`, add `reconnect()` | +| `ergon/ergon_core/ergon_core/core/sandbox/manager.py` | Stage 1: move six dicts to `__init__`, fix `_event_sink` init; Stage 2: remove `__new__` + `_instance`, add `reconnect()` | | `ergon/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py` | Stage 3: update `ensure_sandbox()` to use `reconnect()` on cross-process miss | | `ergon/ergon_core/ergon_core/core/runtime/evaluation/evaluation_schemas.py` | Stage 3: add `sandbox_id: str \| None = None` to `CriterionContext` if absent | | `ergon/ergon_builtins/ergon_builtins/workers/baselines/minif2f_react_worker.py` | Stage 3: replace `manager.get_sandbox(context.task_id)` with `reconnect` or DI | @@ -567,7 +567,7 @@ from uuid import uuid4 import pytest -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager class _MinimalManager(BaseSandboxManager): @@ -610,12 +610,12 @@ class TestInstanceIsolation: ) def test_event_sink_initialized_in_init(self) -> None: - from ergon_core.core.providers.sandbox.event_sink import NoopSandboxEventSink + from ergon_core.core.sandbox.event_sink import NoopSandboxEventSink m = _MinimalManager() assert isinstance(m._event_sink, NoopSandboxEventSink) def test_custom_event_sink_set_without_stomp(self) -> None: - from ergon_core.core.providers.sandbox.event_sink import NoopSandboxEventSink + from ergon_core.core.sandbox.event_sink import NoopSandboxEventSink sink_a = NoopSandboxEventSink() sink_b = NoopSandboxEventSink() m1 = _MinimalManager(event_sink=sink_a) @@ -648,7 +648,7 @@ class TestReconnect: @pytest.mark.asyncio async def test_reconnect_calls_connect(self, monkeypatch: pytest.MonkeyPatch) -> None: - from ergon_core.core.providers.sandbox import manager as mgr_module + from ergon_core.core.sandbox import manager as mgr_module fake_sandbox = MagicMock() fake_connect = AsyncMock(return_value=fake_sandbox) @@ -667,7 +667,7 @@ class TestReconnect: async def test_reconnect_raises_when_e2b_not_installed( self, monkeypatch: pytest.MonkeyPatch ) -> None: - from ergon_core.core.providers.sandbox import manager as mgr_module + from ergon_core.core.sandbox import manager as mgr_module monkeypatch.setattr(mgr_module, "AsyncSandbox", None) @@ -680,7 +680,7 @@ class TestReconnect: self, monkeypatch: pytest.MonkeyPatch ) -> None: """reconnect() must not populate self._sandboxes (stateless by design).""" - from ergon_core.core.providers.sandbox import manager as mgr_module + from ergon_core.core.sandbox import manager as mgr_module fake_sandbox = MagicMock() fake_connect = AsyncMock(return_value=fake_sandbox) diff --git a/docs/rfcs/active/architecture-refactor-audit/02-test-brittleness-and-gaps.md b/docs/rfcs/active/architecture-refactor-audit/02-test-brittleness-and-gaps.md index 6344e914..1525b9b6 100644 --- a/docs/rfcs/active/architecture-refactor-audit/02-test-brittleness-and-gaps.md +++ b/docs/rfcs/active/architecture-refactor-audit/02-test-brittleness-and-gaps.md @@ -364,7 +364,7 @@ Files: propagation. - Add fake provider helpers under `ergon_core/ergon_core/test_support/` only if they are reusable across test tiers. -- Pair with code cleanup in `core/providers/sandbox/manager.py` only after +- Pair with code cleanup in `core/sandbox/manager.py` only after characterization tests exist. Steps: diff --git a/docs/rfcs/active/architecture-refactor-audit/03-code-quality.md b/docs/rfcs/active/architecture-refactor-audit/03-code-quality.md index d541ef28..864c88a4 100644 --- a/docs/rfcs/active/architecture-refactor-audit/03-code-quality.md +++ b/docs/rfcs/active/architecture-refactor-audit/03-code-quality.md @@ -271,7 +271,7 @@ Verification: Files: -- Review: `ergon_core/ergon_core/core/providers/sandbox/manager.py`. +- Review: `ergon_core/ergon_core/core/sandbox/manager.py`. - Review: `ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py`. - Review: `ergon_core/ergon_core/core/rl/eval_runner.py`. - Review: `ergon_core/ergon_core/test_support/smoke_fixtures/`. diff --git a/docs/rfcs/active/final-worker-output-source-of-truth.md b/docs/rfcs/active/final-worker-output-source-of-truth.md new file mode 100644 index 00000000..09495a56 --- /dev/null +++ b/docs/rfcs/active/final-worker-output-source-of-truth.md @@ -0,0 +1,177 @@ +# Final Worker Output Source of Truth + +_Sketch for treating `WorkerOutput` as the semantic final answer, rather than inferring it from context transcript events._ + +--- + +## Problem + +`ReActWorker.get_output()` currently reconstructs the worker's final output by reading persisted `RunContextEvent` rows and taking the last `assistant_text`, with a fallback that searches for a `final_result` tool call. That works, but it conflates three different concepts: + +- `assistant_text`: model text emitted during a generation turn +- `tool_call(final_result)`: PydanticAI's structured-output protocol +- `WorkerOutput`: the worker's final semantic result for the task execution + +The final answer should not be inferred from transcript shape. It should be the explicit output returned by the worker and persisted by the runtime. + +## Current State + +The codebase already has most of the right destination: + +- `WorkerOutput(output=..., success=..., metadata=...)` is the worker API's semantic final result. +- `worker_execute_fn()` receives the worker's `WorkerOutput` after `worker.get_output(worker_context)`. +- `WorkerExecuteResult.final_assistant_message` carries that value from `worker-execute` back to `task-execute`. +- `execute_task_fn()` passes `worker_result.final_assistant_message` into `FinalizeTaskExecutionCommand`. +- `TaskExecutionService.finalize_success()` persists it to `RunTaskExecution.final_assistant_message`. +- `RunTaskExecution` also has `output_json` for structured execution output metadata. + +So the persistence model already has a first-class execution-level field for the final assistant message. The weak part is upstream: `ReActWorker.get_output()` still computes that value by re-reading the context-event transcript. + +## Desired Shape + +The runtime should treat final worker output as execution-level data, not as another transcript event. + +```text +worker.execute() yields GenerationTurn events + | + v +ContextEventRepository persists transcript evidence + | + v +worker.get_output() returns WorkerOutput + | + v +TaskExecutionService.finalize_success() persists execution result + | + v +RunTaskExecution.final_assistant_message / output_json are the source of truth +``` + +In this model: + +- `RunContextEvent` remains the append-only transcript log. +- `RunTaskExecution.final_assistant_message` is the final human-readable answer. +- `RunTaskExecution.output_json` can hold structured metadata from `WorkerOutput.metadata`. +- Rollout-card export reads both: context events for the trace, task execution fields for final execution outputs. + +## Proposed Contract + +`WorkerOutput` should be the only object that defines a worker's final semantic output. + +```python +class WorkerOutput(BaseModel): + output: str + success: bool = True + metadata: dict[str, Any] = Field(default_factory=dict) +``` + +The runtime should persist it as: + +```text +RunTaskExecution.final_assistant_message = WorkerOutput.output +RunTaskExecution.output_json = { + "worker_output": { + "success": WorkerOutput.success, + "metadata": WorkerOutput.metadata, + }, + "resource_ids": [...] +} +``` + +If we want the full `WorkerOutput` object available in exports, use `output_json["worker_output"]` rather than adding a new `RunContextEvent` type. + +## ReActWorker Implication + +`ReActWorker` should stop deriving output by querying `ContextEventRepository`. + +Instead, it should capture the structured final result while running the PydanticAI agent. The worker already configures: + +```python +agent: Agent[None, _AgentOutput] = Agent( + model=resolved.model, + instructions=self.system_prompt or None, + tools=self.tools, + output_type=_AgentOutput, +) +``` + +The final `_AgentOutput.final_assistant_message` should be stored on the worker instance during `execute()`, then returned directly from `get_output()`. + +Conceptually: + +```python +class ReActWorker(Worker): + def __init__(...): + ... + self._final_output: _AgentOutput | None = None + self._turn_count = 0 + + async def _run_agent(...): + async with agent.iter(...) as run: + ... + self._final_output = run.result.output + + def get_output(self, context: WorkerContext) -> WorkerOutput: + if self._final_output is None: + return WorkerOutput(output="", success=False) + return WorkerOutput( + output=self._final_output.final_assistant_message, + success=True, + metadata={ + "reasoning": self._final_output.reasoning, + "turn_count": self._turn_count, + }, + ) +``` + +The exact PydanticAI result access may differ, but the ownership is the important part: the worker returns the structured final result it received from the agent, rather than reconstructing it from persisted context events. + +## Why Not `final_agent_message` Context Events? + +A new context event type would make the transcript easier to query, but it blurs the abstraction boundary. + +`RunContextEvent` should answer: "What happened during the model/tool interaction?" + +`RunTaskExecution` should answer: "What did this worker execution finally produce?" + +The final output belongs to the second question. Mirroring it into a rollout-card export is useful; storing it as another transcript event is optional and should not be the source of truth. + +## Implementation Sketch + +1. Keep `ContextEventRepository` unchanged as the transcript serializer. +2. Update `WorkerExecuteResult` only if needed to carry `WorkerOutput.metadata`. +3. Update `FinalizeTaskExecutionCommand` to carry `worker_output_metadata` or a full `worker_output_json`. +4. Update `TaskExecutionService.finalize_success()` to persist: + - `final_assistant_message` + - `output_json["worker_output"]` + - existing `resource_ids` if present +5. Update `ReActWorker` to capture its PydanticAI structured result during execution. +6. Replace `ReActWorker._base_output()` with a simple read of the captured structured output. +7. Remove `_latest_final_result_message()` if no other worker needs it. +8. Update rollout-card export to include task execution final outputs from `RunTaskExecution`, not by scanning `RunContextEvent`. + +## Migration / Compatibility + +Existing completed runs may only have context events, so readers should remain tolerant: + +- Prefer `RunTaskExecution.final_assistant_message`. +- If absent, optionally fall back to the old transcript inference for legacy runs. +- Do not use the fallback in new execution paths. + +This preserves old data while making new runs explicit. + +## Tests + +Add focused tests for: + +- `ReActWorker.get_output()` returns the captured structured `_AgentOutput`, not the last `assistant_text`. +- A run with intermediate `assistant_text` plus final structured output persists the structured final output. +- `TaskExecutionService.finalize_success()` writes `final_assistant_message` and `output_json["worker_output"]`. +- Context event replay still reconstructs transcript messages without needing final-output semantics. +- Legacy read helpers fall back to transcript inference only when `RunTaskExecution.final_assistant_message` is missing. + +## Open Questions + +1. Should `WorkerExecuteResult` carry the full `WorkerOutput.metadata`, or should `worker_execute_fn()` persist it directly before returning? +2. Should `RunTaskExecution.output_json` store the full `WorkerOutput` shape, or only `metadata` plus resource references? +3. Should rollout-card export call this field `worker_output`, `execution_output`, or `final_worker_output`? diff --git a/docs/superpowers/plans/2026-04-27-react-worker-failure-context-capture.md b/docs/superpowers/plans/2026-04-27-react-worker-failure-context-capture.md new file mode 100644 index 00000000..e730dc6f --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-react-worker-failure-context-capture.md @@ -0,0 +1,650 @@ +# ReAct Worker Failure Context Capture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve partial PydanticAI ReAct transcript history when `agent.iter(...)` raises before `ReActWorker._run_agent()` reaches its normal post-run transcript extraction. + +**Architecture:** Keep runtime persistence ownership in `worker_execute_fn()`: workers yield `GenerationTurn`, runtime persists `RunContextEvent`. Add an incremental/cursor-based extraction API to `PydanticAITranscriptAdapter` so `ReActWorker` can yield completed turns during normal iteration and flush any remaining partial turn in an exception path before re-raising. This keeps failure semantics intact while eliminating the current zero-context failure gap for failed ReAct/CLI child workers. + +**Tech Stack:** Python, PydanticAI `Agent.iter`, `GenerationTurn`, `PydanticAITranscriptAdapter`, `ContextEventRepository`, pytest. + +--- + +## Root Cause + +Current `ReActWorker._run_agent()` only converts PydanticAI messages into `GenerationTurn`s after the `agent.iter(...)` context exits normally: + +```python +async with agent.iter(...) as run: + async for _node in run: + ... + +turns = PydanticAITranscriptAdapter().build_turns(run.ctx.state.message_history) +for turn in turns: + yield turn +``` + +If PydanticAI raises inside `async for _node in run`, control jumps out of `_run_agent()` before `build_turns(...)` runs. Then `worker_execute_fn()` catches the exception before it has received any turns to persist. That explains executions with an error stack but `0` `RunContextEvent` rows. + +The ResearchRubrics workflow CLI worker is affected because it subclasses `ReActWorker`: + +```python +async for turn in super().execute(task, context=context): + yield turn +``` + +Successful CLI runs use the shared adapter; failed CLI runs can still lose partial transcript history. + +--- + +## Desired Behavior + +- Successful ReAct runs keep capturing the same full transcript as today. +- Failed ReAct runs yield/persist every turn that can be reconstructed from `run.ctx.state.message_history` before re-raising the original exception. +- Runtime failure semantics do not change: `worker_execute_fn()` still returns the failure result and task status remains failed. +- Workers do not call `ContextEventRepository` directly. +- No duplicate context events are emitted when incremental extraction is called multiple times. +- Partial trailing responses can be flushed on final success or failure, but not emitted prematurely while a tool call may still receive a following `ToolReturnPart`. + +--- + +## File Map + +```text +ergon_builtins/ + ergon_builtins/ + common/ + llm_context/ + adapters/ + pydantic_ai.py # modify: replace post-run-only turn extraction with cursor API + workers/ + baselines/ + react_worker.py # modify: yield incremental turns and flush on exception + +tests/ + unit/ + builtins/ + common/ + test_transcript_adapters.py # modify: cursor extraction + trailing flush tests + workers/ + test_react_worker_contract.py # modify or add tests for failure transcript yield/re-raise +``` + +Do not modify `worker_execute_fn()` for this fix unless tests prove it cannot persist turns yielded immediately before an async generator raises. The existing `async for turn in worker.execute(...)` loop already persists each yielded turn before requesting the next one. + +--- + +## Closure And Removals + +This is not an additive second serialization path. Close the old behavior explicitly: + +- Remove `ReActWorker._run_agent()`'s post-run-only extraction pattern: + +```python +turns = PydanticAITranscriptAdapter().build_turns(run.ctx.state.message_history) +for turn in turns: + yield turn +``` + +Replace it with cursor extraction during the loop plus final/failure flush. + +- Do not add a new repository or direct DB writer for failure capture. `ContextEventRepository` remains the only `GenerationTurn` -> `RunContextEvent` serializer, and it remains called by `worker_execute_fn()`. +- Do not restore the old core PydanticAI serializers removed in the previous refactor: `ergon_core/core/persistence/context/assembly.py` and `ergon_core/core/providers/generation/pydantic_ai_format.py`. +- Do not add any new `ergon_core` PydanticAI transcript code. All PydanticAI transcript extraction/replay stays in `ergon_builtins.common.llm_context.adapters.pydantic_ai`. +- Treat the cursor API as the runtime extraction surface. If a batch `build_turns(...)` helper remains for tests or protocol compatibility, implement it as a wrapper around the same cursor extraction logic, not as a second independent serializer. +- Update tests that assert the worker no longer owns parser helpers so they also assert `ReActWorker` does not call a post-run-only extraction helper directly. + +There is no separate old "turn serialization repository" to delete after the previous refactor. The durable serialization repository is still `ContextEventRepository`, and that should stay. The old thing to remove here is the worker's post-run-only transcript extraction path, because it is the failure gap. + +--- + +## Design + +Use a small cursor object in the PydanticAI adapter: + +```python +from pydantic import BaseModel + + +class TranscriptTurnCursor(BaseModel): + model_config = {"validate_assignment": True} + + emitted_turn_count: int = 0 +``` + +Make cursor extraction the runtime API: + +```python +class PydanticAITranscriptAdapter(...): + def build_new_turns( + self, + transcript: list[ModelMessage], + cursor: TranscriptTurnCursor, + *, + flush_pending: bool = False, + ) -> list[GenerationTurn]: + turns = _build_turns_from_transcript(transcript, flush_pending=flush_pending) + new_turns = turns[cursor.emitted_turn_count :] + cursor.emitted_turn_count = len(turns) + return new_turns +``` + +If `build_turns(...)` remains public because `TranscriptAdapter` currently declares it, it should delegate to the same internal implementation used by `build_new_turns(...)`. Do not keep two independent conversion implementations. + +Change current trailing-response behavior in `build_turns()` so it is explicit: + +```python +if pending_response is not None and flush_pending: + turns.append(_to_turn(pending_request_in, pending_response, tool_result_request=None)) +``` + +`flush_pending=False` is important during the live `agent.iter(...)` loop. It prevents emitting a tool-call response before the following `ModelRequest` has a chance to include the `ToolReturnPart`. On final success or failure, use `flush_pending=True` so partial model output is not lost. + +Update `ReActWorker._run_agent()`: + +```python +adapter = PydanticAITranscriptAdapter() +cursor = TranscriptTurnCursor() +run = None + +try: + async with agent.iter(...) as active_run: + run = active_run + async for _node in run: + node_count += 1 + + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=False, + ): + yield turn + + if node_count >= self.max_iterations: + logger.warning(...) + break +except Exception: + if run is not None: + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=True, + ): + yield turn + raise + +if run is not None: + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=True, + ): + yield turn +``` + +This is extraction-as-iterator in practice: the cursor marks what has already been yielded, and `build_new_turns(...)` can be called repeatedly as message history grows. + +Do not swallow exceptions. The final `raise` is required so `worker_execute_fn()` still records failure. + +--- + +## Task 1: Adapter Cursor API + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py` +- Modify: `tests/unit/builtins/common/test_transcript_adapters.py` + +- [ ] **Step 1: Write failing test for no premature trailing response** + +Add to `tests/unit/builtins/common/test_transcript_adapters.py`: + +```python +from ergon_builtins.common.llm_context.adapters.pydantic_ai import TranscriptTurnCursor + + +def test_incremental_extraction_does_not_emit_pending_tool_call_response() -> None: + adapter = PydanticAITranscriptAdapter() + cursor = TranscriptTurnCursor() + transcript = [ + ModelRequest(parts=[UserPromptPart(content="search")]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name="search", + tool_call_id="call-1", + args={"query": "ergon"}, + ) + ] + ), + ] + + assert adapter.build_new_turns(transcript, cursor, flush_pending=False) == [] + + flushed = adapter.build_new_turns(transcript, cursor, flush_pending=True) + assert len(flushed) == 1 + assert any(isinstance(part, ErgonToolCallPart) for part in flushed[0].response_parts) +``` + +- [ ] **Step 2: Write failing test for no duplicate new turns** + +Add: + +```python +def test_incremental_extraction_tracks_emitted_turns() -> None: + adapter = PydanticAITranscriptAdapter() + cursor = TranscriptTurnCursor() + transcript = [ + ModelRequest(parts=[UserPromptPart(content="search")]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name="search", + tool_call_id="call-1", + args={"query": "ergon"}, + ) + ] + ), + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="search", + tool_call_id="call-1", + content={"result": "found"}, + ) + ] + ), + ] + + first = adapter.build_new_turns(transcript, cursor, flush_pending=False) + second = adapter.build_new_turns(transcript, cursor, flush_pending=False) + + assert len(first) == 1 + assert second == [] +``` + +- [ ] **Step 3: Run red tests** + +Run: + +```bash +uv run pytest tests/unit/builtins/common/test_transcript_adapters.py -q +``` + +Expected: FAIL because `TranscriptTurnCursor` and `build_new_turns()` do not exist. + +- [ ] **Step 4: Replace batch extraction internals with cursor-backed extraction** + +In `pydantic_ai.py`, add: + +```python +from pydantic import BaseModel + + +class TranscriptTurnCursor(BaseModel): + model_config = {"validate_assignment": True} + + emitted_turn_count: int = 0 +``` + +Move the existing `build_turns(...)` body into a private helper that takes `flush_pending`: + +```python +def _build_turns_from_transcript( + transcript: list[ModelMessage], + *, + flush_pending: bool, +) -> list[GenerationTurn]: + ... +``` + +Keep `build_turns(...)` only as compatibility with the existing `TranscriptAdapter` protocol and any batch tests: + +```python +def build_turns( + self, + transcript: list[ModelMessage], + *, + flush_pending: bool = True, +) -> list[GenerationTurn]: + return _build_turns_from_transcript(transcript, flush_pending=flush_pending) +``` + +Do not call `build_turns(...)` from `ReActWorker`. Runtime extraction should use the cursor API only. + +Change trailing append: + +```python +if pending_response is not None: + turns.append(_to_turn(pending_request_in, pending_response, tool_result_request=None)) +``` + +to: + +```python +if pending_response is not None and flush_pending: + turns.append(_to_turn(pending_request_in, pending_response, tool_result_request=None)) +``` + +Add: + +```python +def build_new_turns( + self, + transcript: list[ModelMessage], + cursor: TranscriptTurnCursor, + *, + flush_pending: bool = False, +) -> list[GenerationTurn]: + turns = _build_turns_from_transcript(transcript, flush_pending=flush_pending) + new_turns = turns[cursor.emitted_turn_count :] + cursor.emitted_turn_count = len(turns) + return new_turns +``` + +After this change, there is one conversion implementation: `_build_turns_from_transcript(...)`. `build_turns(...)` and `build_new_turns(...)` are wrappers with different calling semantics. + +- [ ] **Step 5: Run green tests** + +Run: + +```bash +uv run pytest tests/unit/builtins/common/test_transcript_adapters.py -q +``` + +Expected: PASS. + +--- + +## Task 2: ReActWorker Failure Flush + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/workers/baselines/react_worker.py` +- Modify: `tests/unit/workers/test_react_worker_contract.py` + +- [ ] **Step 1: Write failing test for partial yield then re-raise** + +Add a fake `Agent` to `tests/unit/workers/test_react_worker_contract.py`: + +```python +from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart + + +class _FakeRunState: + def __init__(self): + self.message_history = [ + ModelRequest(parts=[UserPromptPart(content="question")]), + ModelResponse(parts=[TextPart(content="partial answer")]), + ] + + +class _FakeRunContext: + def __init__(self): + self.state = _FakeRunState() + + +class _FailingAgentRun: + def __init__(self): + self.ctx = _FakeRunContext() + + def __aiter__(self): + return self + + async def __anext__(self): + raise RuntimeError("tool validation failed") + + +class _FailingAgentIter: + async def __aenter__(self): + return _FailingAgentRun() + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FailingAgent: + def __init__(self, **kwargs): + pass + + def iter(self, *args, **kwargs): + return _FailingAgentIter() +``` + +Then add: + +```python +@pytest.mark.asyncio +async def test_react_worker_yields_partial_turn_before_reraising_agent_iter_failure(monkeypatch) -> None: + import ergon_builtins.workers.baselines.react_worker as react_worker + + monkeypatch.setattr(react_worker, "Agent", _FailingAgent) + monkeypatch.setattr( + react_worker, + "resolve_model_target", + lambda model: type( + "Resolved", + (), + {"model": "stub:constant", "capture_model_settings": None}, + )(), + ) + + worker = ReActWorker( + name="unit", + model=None, + task_id=UUID(int=1), + sandbox_id="test-sandbox", + tools=[], + system_prompt=None, + max_iterations=10, + ) + task = _minimal_task() + + turns = [] + with pytest.raises(RuntimeError, match="tool validation failed"): + async for turn in worker.execute(task, context=_minimal_context()): + turns.append(turn) + + assert len(turns) == 1 + assert any(part.content == "partial answer" for part in turns[0].response_parts) +``` + +Add small local helpers if this test file does not already have task/context fixtures: + +```python +from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload +from ergon_core.api.worker_context import WorkerContext + + +def _minimal_task() -> BenchmarkTask: + return BenchmarkTask( + task_id=UUID(int=2), + task_slug="unit-task", + description="Unit task", + task_payload=EmptyTaskPayload(), + ) + + +def _minimal_context() -> WorkerContext: + return WorkerContext( + run_id=UUID(int=3), + definition_id=UUID(int=4), + task_id=UUID(int=2), + execution_id=UUID(int=5), + sandbox_id="test-sandbox", + node_id=UUID(int=6), + ) +``` + +- [ ] **Step 2: Run red test** + +Run: + +```bash +uv run pytest tests/unit/workers/test_react_worker_contract.py::test_react_worker_yields_partial_turn_before_reraising_agent_iter_failure -q +``` + +Expected: FAIL because `_run_agent()` currently re-raises before yielding the partial transcript. + +- [ ] **Step 3: Implement failure flush in `_run_agent()`** + +Modify `ReActWorker._run_agent()`: + +```python +adapter = PydanticAITranscriptAdapter() +cursor = TranscriptTurnCursor() +run = None + +try: + async with agent.iter( + task_prompt, + model_settings=resolved.capture_model_settings, + message_history=self._seed_messages, + ) as active_run: + run = active_run + async for _node in run: + node_count += 1 + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=False, + ): + yield turn + if node_count >= self.max_iterations: + logger.warning(...) + break +except Exception: + if run is not None: + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=True, + ): + yield turn + raise + +if run is not None: + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=True, + ): + yield turn +``` + +Keep the existing warning text for `max_iterations`. + +- [ ] **Step 4: Run worker test** + +Run: + +```bash +uv run pytest tests/unit/workers/test_react_worker_contract.py -q +``` + +Expected: PASS. + +--- + +## Task 3: Runtime Persistence Regression + +**Files:** +- Modify: `tests/unit/runtime/test_failure_error_json.py` or add `tests/unit/runtime/test_worker_execute_partial_failure_context.py` + +- [ ] **Step 1: Add runtime-level regression if feasible** + +Add a unit test around `worker_execute_fn()` with a fake registered worker whose `execute()` yields one `GenerationTurn` and then raises. Assert that `ContextEventRepository.persist_turn()` is called before the failure result is returned. + +If existing `worker_execute_fn()` setup makes this too fixture-heavy, keep the worker-level test from Task 2 as the required regression and add a short comment in the test explaining why it is sufficient: + +```python +# worker_execute_fn persists each yielded turn before requesting the next item +# from the async generator, so this test covers the failure-capture contract at +# the worker boundary without rebuilding Inngest context fixtures. +``` + +- [ ] **Step 2: Run focused runtime/worker tests** + +Run: + +```bash +uv run pytest tests/unit/workers/test_react_worker_contract.py tests/unit/persistence/test_context_event_repository.py -q +``` + +Expected: PASS. + +--- + +## Task 4: Verification + +**Files:** +- No production edits. + +- [ ] **Step 1: Run affected capture suite** + +Run: + +```bash +uv run pytest \ + tests/unit/builtins/common/test_transcript_adapters.py \ + tests/unit/persistence/test_context_event_repository.py \ + tests/unit/workers/test_react_worker_contract.py \ + tests/unit/state/test_generation_turn_build.py \ + tests/unit/state/test_context_assembly.py \ + -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run lint/compile** + +Run: + +```bash +uv run ruff check \ + ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py \ + ergon_builtins/ergon_builtins/workers/baselines/react_worker.py \ + tests/unit/builtins/common/test_transcript_adapters.py \ + tests/unit/workers/test_react_worker_contract.py +uv run slopcop \ + ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py \ + ergon_builtins/ergon_builtins/workers/baselines/react_worker.py +uv run python -m compileall -q \ + ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py \ + ergon_builtins/ergon_builtins/workers/baselines/react_worker.py +``` + +Expected: PASS. + +- [ ] **Step 3: Optional real-run validation** + +Trigger a ReAct/CLI worker failure after the PydanticAI run has started, then inspect: + +```bash +RUN_ID= python - <<'PY' +from uuid import UUID +from sqlmodel import select +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.persistence.context.models import RunContextEvent + +run_id = UUID(__import__("os").environ["RUN_ID"]) +with get_session() as session: + rows = session.exec( + select(RunContextEvent) + .where(RunContextEvent.run_id == run_id) + .order_by(RunContextEvent.task_execution_id, RunContextEvent.sequence) + ).all() + for row in rows: + print(row.task_execution_id, row.sequence, row.event_type) +PY +``` + +Expected: the failed child execution has at least the partial model request/response/tool-call events that existed before the exception. + +--- + +## Self-Review + +- Spec coverage: The plan addresses the observed gap where `agent.iter(...)` raises before post-run extraction, including CLI workers through `ReActWorker` inheritance. +- Iterator question: The plan proposes cursor-based incremental extraction from growing `message_history`, which is the appropriate iterator shape for PydanticAI histories. +- Persistence boundary: The plan keeps `ContextEventRepository` in the runtime path and does not make workers write directly to the DB. +- Failure semantics: The original exception is re-raised after partial turns are yielded. +- Known limitation: If `agent.iter(...)` fails during `__aenter__` before a `run` object exists, there is no PydanticAI `message_history` to flush. That case should still produce normal task failure metadata, but cannot produce transcript events. diff --git a/docs/superpowers/plans/2026-04-28-agent-tool-budget-harness.md b/docs/superpowers/plans/2026-04-28-agent-tool-budget-harness.md new file mode 100644 index 00000000..7af71aa7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-agent-tool-budget-harness.md @@ -0,0 +1,811 @@ +# Agent Tool Budget Harness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a simple, reusable tool-budget harness that prevents agent rollouts from looping indefinitely by counting `workflow` tool calls separately from all other tool calls and returning explicit budget-exhausted messages when either limit is reached. + +**Architecture:** Use Pydantic AI dependency injection. `ReActWorker` passes an optional deps object into `Agent.iter(...)`; tools that participate in the budget accept `RunContext[AgentToolBudgetDeps]` and call `ctx.deps.tool_budget.check(...)` before doing work. The budget system is generic and benchmark-agnostic: it knows only `workflow` vs `other`, not ResearchRubrics, Exa, or rubric-specific concepts. Reference: [Pydantic AI dependencies](https://pydantic.dev/docs/ai/core-concepts/dependencies/). + +**Tech Stack:** Python 3.13, pydantic-ai `RunContext`, Ergon `ReActWorker`, existing tool callables, pytest smoke checks, real-LLM rollout artifacts, Logfire. + +--- + +## Design + +The harness should enforce two counters per agent execution: + +```python +workflow_tool_calls <= max_workflow_tool_calls +other_tool_calls <= max_other_tool_calls +``` + +Initial defaults: + +```python +AgentToolBudgetPolicy( + max_workflow_tool_calls=12, + max_other_tool_calls=12, + warning_at_remaining=3, +) +``` + +The budget does not decide which benchmark is running and does not know about Exa. It only sees: + +- `workflow` calls: the workflow CLI tool. +- `other` calls: context-gathering and workspace-inspection tools other than `workflow`. +- `finalization` calls: tools that produce final output artifacts, such as report writing. These count for observability but are not blocked, because the budget should push the agent into finalization rather than prevent it. + +When a limit is reached, the tool returns a normal structured tool result: + +```python +AgentToolBudgetExhaustedResult( + status="TOOL_BUDGET_EXHAUSTED", + reason="workflow tool budget reached", + message="Stop calling workflow. Use currently visible context/resources and produce the best possible final output.", + budget_state={...}, +) +``` + +or: + +```python +AgentToolBudgetExhaustedResult( + status="TOOL_BUDGET_EXHAUSTED", + reason="non-workflow tool budget reached", + message="Stop calling tools. Produce the final answer from the context already gathered.", + budget_state={...}, +) +``` + +This is intentionally not a Python exception. The model gets a final chance to converge. The outer `max_iterations` guard still raises a real error if the agent keeps looping after exhausted tool responses. + +## Package Placement + +- Generic budget state: `ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py` +- Base agent execution hook: `ergon_builtins/ergon_builtins/workers/baselines/react_worker.py` +- Budgeted workflow command tool: `ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py` +- Budgeted non-workflow tools for this rollout: `ergon_builtins/ergon_builtins/tools/research_rubrics_toolkit.py` and `ergon_builtins/ergon_builtins/tools/graph_toolkit.py` +- Worker-specific budget policy wiring: `ergon_builtins/ergon_builtins/workers/research_rubrics/` +- Rollout diagnostics: `tests/real_llm/` + +## Added Files + +```text +ergon_builtins/ + ergon_builtins/ + workers/ + baselines/ + tool_budget.py +``` + +`tool_budget.py` owns the generic Pydantic models for budget policy, mutable per-execution budget state, deps passed into pydantic-ai, and helper logic for attaching warning text to tool results. + +## Edited Files + +```text +ergon_builtins/ + ergon_builtins/ + tools/ + graph_toolkit.py + research_rubrics_toolkit.py + workflow_cli_tool.py + workers/ + baselines/ + react_worker.py + research_rubrics/ + researcher_worker.py + workflow_cli_react_worker.py + +tests/ + real_llm/ + artifact_health.py + rollout.py +``` + +Edit responsibilities: + +- `react_worker.py`: add an optional deps hook, pass deps into `Agent.iter(...)`, and raise when `max_iterations` is hit. +- `workflow_cli_tool.py`: edit the existing workflow tool function path to support a ctx-taking budgeted mode for `workflow` calls. +- `research_rubrics_toolkit.py`: convert participating tools to ctx-taking functions and count context-gathering tools as `other`, while allowing report-writing as `finalization`. +- `graph_toolkit.py`: convert graph/resource tools to ctx-taking functions and count them as `other`. +- `researcher_worker.py`: provide generic budget deps to `ReActWorker` and steer the prompt toward quick convergence. +- `workflow_cli_react_worker.py`: provide generic budget deps, use budgeted workflow tool mode, and steer the prompt toward deliberate workflow use and subagent coordination. +- `artifact_health.py`: derive `workflow_tool_calls`, `other_tool_calls`, `budget_exhausted`, and `missing_final_report` from existing rollout artifacts. +- `rollout.py`: include those derived counters in `report.md`. + +## Deleted Files + +```text +(none) +``` + +## Optional Later Files + +If other benchmarks start showing the same loop behavior, apply the same `RunContext[AgentToolBudgetDeps]` pattern to their toolkits: + +```text +ergon_builtins/ + ergon_builtins/ + benchmarks/ + gdpeval/ + toolkit.py + minif2f/ + toolkit.py + swebench_verified/ + toolkit.py +``` + +--- + +## Task 1: Add Generic Tool Budget State + +**Files:** +- Create: `ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py` + +- [ ] **Step 1: Create generic budget types** + +Create `tool_budget.py`: + +```python +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +ToolBudgetKind = Literal["workflow", "other", "finalization"] +ToolBudgetExhaustedStatus = Literal["TOOL_BUDGET_EXHAUSTED"] + + +class AgentToolBudgetExhaustedResult(BaseModel): + status: ToolBudgetExhaustedStatus = "TOOL_BUDGET_EXHAUSTED" + reason: str + message: str + budget_state: dict[str, Any] # slopcop: ignore[no-typing-any] + + +class AgentToolBudgetPolicy(BaseModel): + model_config = {"frozen": True} + + max_workflow_tool_calls: int = 12 + max_other_tool_calls: int = 12 + warning_at_remaining: int = 3 + + +class AgentToolBudgetDecision(BaseModel): + model_config = {"frozen": True} + + allowed: bool + warning: str | None = None + exhausted: AgentToolBudgetExhaustedResult | None = None + + +class AgentToolBudgetState(BaseModel): + policy: AgentToolBudgetPolicy = Field(default_factory=AgentToolBudgetPolicy) + workflow_tool_calls: int = 0 + other_tool_calls: int = 0 + finalization_tool_calls: int = 0 + calls_by_tool: dict[str, int] = Field(default_factory=dict) + + def check(self, tool_name: str, kind: ToolBudgetKind) -> AgentToolBudgetDecision: + self.calls_by_tool[tool_name] = self.calls_by_tool.get(tool_name, 0) + 1 + + if kind == "workflow": + self.workflow_tool_calls += 1 + if self.workflow_tool_calls > self.policy.max_workflow_tool_calls: + return AgentToolBudgetDecision( + allowed=False, + exhausted=self.exhausted_result("workflow tool budget reached"), + ) + remaining = self.policy.max_workflow_tool_calls - self.workflow_tool_calls + elif kind == "finalization": + self.finalization_tool_calls += 1 + return AgentToolBudgetDecision(allowed=True) + else: + self.other_tool_calls += 1 + if self.other_tool_calls > self.policy.max_other_tool_calls: + return AgentToolBudgetDecision( + allowed=False, + exhausted=self.exhausted_result("non-workflow tool budget reached"), + ) + remaining = self.policy.max_other_tool_calls - self.other_tool_calls + + if remaining <= self.policy.warning_at_remaining: + return AgentToolBudgetDecision( + allowed=True, + warning=( + f"TOOL_BUDGET_WARNING: {remaining} {kind} tool calls remain. " + "Converge now using the context already gathered." + ), + ) + return AgentToolBudgetDecision(allowed=True) + + def snapshot(self) -> dict[str, Any]: # slopcop: ignore[no-typing-any] + return { + "workflow_tool_calls": self.workflow_tool_calls, + "max_workflow_tool_calls": self.policy.max_workflow_tool_calls, + "other_tool_calls": self.other_tool_calls, + "max_other_tool_calls": self.policy.max_other_tool_calls, + "finalization_tool_calls": self.finalization_tool_calls, + "calls_by_tool": dict(sorted(self.calls_by_tool.items())), + } + + def exhausted_result(self, reason: str) -> AgentToolBudgetExhaustedResult: + return AgentToolBudgetExhaustedResult( + reason=reason, + message=( + "Stop calling tools in this category. Use the context/resources already " + "available and produce the best possible final output. If the output is " + "incomplete, state what context or resource was missing." + ), + budget_state=self.snapshot(), + ) + + +class AgentToolBudgetDeps(BaseModel): + tool_budget: AgentToolBudgetState + + +def with_budget_warning(result: Any, warning: str | None) -> Any: # slopcop: ignore[no-typing-any] + if warning is None: + return result + if isinstance(result, str): + return f"{result}\n\n{warning}" + if isinstance(result, dict): + updated = dict(result) + updated["tool_budget_warning"] = warning + return updated + return result +``` + +- [ ] **Step 2: Run import smoke check** + +Run: + +```bash +uv run python - <<'PY' +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetPolicy, + AgentToolBudgetState, +) + +state = AgentToolBudgetState( + policy=AgentToolBudgetPolicy(max_workflow_tool_calls=1, max_other_tool_calls=2), +) +deps = AgentToolBudgetDeps(tool_budget=state) +print(deps.tool_budget.check("workflow", "workflow").allowed) +print(deps.tool_budget.check("workflow", "workflow").allowed) +print(deps.tool_budget.snapshot()) +PY +``` + +Expected: first line `True`, second line `False`, then a snapshot dictionary. + +--- + +## Task 2: Pass Deps Through ReActWorker + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/workers/baselines/react_worker.py` + +- [ ] **Step 1: Add a deps hook** + +Add to `ReActWorker`: + +```python + def build_agent_deps(self, context: WorkerContext) -> Any | None: # slopcop: ignore[no-typing-any] + return None +``` + +- [ ] **Step 2: Pass context into `_run_agent`** + +Change: + +```python +async for turn in self._run_agent(task): +``` + +to: + +```python +async for turn in self._run_agent(task, context): +``` + +Change `_run_agent` signature: + +```python + async def _run_agent( + self, + task: BenchmarkTask, + context: WorkerContext, + ) -> AsyncGenerator[GenerationTurn, None]: +``` + +- [ ] **Step 3: Pass deps to pydantic-ai** + +Before `Agent(...)`: + +```python + agent_deps = self.build_agent_deps(context) + deps_type = type(agent_deps) if agent_deps is not None else None +``` + +Change the agent construction to include: + +```python + deps_type=deps_type, +``` + +Change `agent.iter(...)` to include: + +```python + deps=agent_deps, +``` + +- [ ] **Step 4: Make max-iteration exhaustion visible** + +Replace the current `break` on `max_iterations` with: + +```python + for turn in adapter.build_new_turns( + run.ctx.state.message_history, + cursor, + flush_pending=True, + ): + yield turn + raise RuntimeError( + f"ReActWorker exceeded max_iterations={self.max_iterations}" + ) +``` + +- [ ] **Step 5: Run existing focused tests** + +Run: + +```bash +uv run pytest tests/unit/workers/test_react_worker_contract.py tests/unit/builtins/common/test_transcript_adapters.py -q +``` + +Expected: PASS. + +--- + +## Task 3: Budget the Workflow Tool + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py` +- Existing test: `tests/unit/state/test_workflow_cli_tool.py` + +- [ ] **Step 1: Add ctx-aware mode** + +Import: + +```python +from pydantic_ai import RunContext +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetExhaustedResult, + with_budget_warning, +) +``` + +Add parameter to `make_workflow_cli_tool`: + +```python + budgeted: bool = False, +``` + +Edit the existing function body directly. Do not add a separate wrapper around workflow execution. Because pydantic-ai needs a clear callable signature, use two function definitions inside `make_workflow_cli_tool`: one ctx-taking definition for `budgeted=True`, and the existing no-ctx definition for `budgeted=False`. + +```python + if budgeted: + async def workflow( + ctx: RunContext[AgentToolBudgetDeps], + command: str, + ) -> str | AgentToolBudgetExhaustedResult: + decision = ctx.deps.tool_budget.check("workflow", "workflow") + if not decision.allowed: + assert decision.exhausted is not None + return decision.exhausted + + if worker_context.node_id is None: + raise ValueError("workflow tool requires WorkerContext.node_id") + + output = await asyncio.to_thread( + execute_command, + command, + context=WorkflowCommandContext( + run_id=worker_context.run_id, + node_id=worker_context.node_id, + execution_id=worker_context.execution_id, + sandbox_task_key=sandbox_task_key, + benchmark_type=benchmark_type, + ), + session_factory=session_factory, + service=service_factory(), + ) + if output.exit_code != 0: + detail = output.stderr or output.stdout + result = f"workflow exited {output.exit_code}: {detail}".strip() + elif output.stderr: + result = f"{output.stdout}\n\nstderr:\n{output.stderr}".strip() + else: + result = output.stdout + return with_budget_warning(result, decision.warning) + + return workflow +``` + +Keep the existing no-ctx `workflow(command: str)` function as the `budgeted=False` branch: + +```python + async def workflow(command: str) -> str: + if worker_context.node_id is None: + raise ValueError("workflow tool requires WorkerContext.node_id") + + output = await asyncio.to_thread( + execute_command, + command, + context=WorkflowCommandContext( + run_id=worker_context.run_id, + node_id=worker_context.node_id, + execution_id=worker_context.execution_id, + sandbox_task_key=sandbox_task_key, + benchmark_type=benchmark_type, + ), + session_factory=session_factory, + service=service_factory(), + ) + if output.exit_code != 0: + detail = output.stderr or output.stdout + return f"workflow exited {output.exit_code}: {detail}".strip() + if output.stderr: + return f"{output.stdout}\n\nstderr:\n{output.stderr}".strip() + return output.stdout + + return workflow +``` + +- [ ] **Step 2: Preserve existing behavior** + +Run: + +```bash +uv run pytest tests/unit/state/test_workflow_cli_tool.py -q +``` + +Expected: PASS. Existing tests use `budgeted=False`. + +--- + +## Task 4: Budget Other Tools Used by This Harness + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/tools/research_rubrics_toolkit.py` +- Modify: `ergon_builtins/ergon_builtins/tools/graph_toolkit.py` + +- [ ] **Step 1: Convert ResearchRubrics tools to ctx-taking functions** + +In `research_rubrics_toolkit.py`, import: + +```python +from pydantic_ai import RunContext +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetExhaustedResult, + with_budget_warning, +) +``` + +For each tool function, add `ctx` as the first arg: + +```python +ctx: RunContext[AgentToolBudgetDeps], +``` + +At the top of each context-gathering tool: + +```python +decision = ctx.deps.tool_budget.check("", "other") +if not decision.allowed: + assert decision.exhausted is not None + return decision.exhausted +``` + +For final-output tools such as `write_report_draft` and `edit_report_draft`, use: + +```python +decision = ctx.deps.tool_budget.check("", "finalization") +``` + +Do not block finalization tools after `other` is exhausted. The budget exists to force convergence into these tools. + +Use the actual function/tool name for each function so `calls_by_tool` remains useful in artifacts. + +After the existing result is produced: + +```python +return cast( | AgentToolBudgetExhaustedResult, with_budget_warning(resp, decision.warning)) +``` + +For response types that are Pydantic models, returning `AgentToolBudgetExhaustedResult` on exhaustion is acceptable because the tool result is serialized back to the model. Keep type annotations broad enough, for example: + +```python +) -> SearchResponse | AgentToolBudgetExhaustedResult: +``` + +Change each `Tool(..., takes_ctx=False)` to: + +```python +Tool(function=..., takes_ctx=True) +``` + +- [ ] **Step 2: Convert graph/resource tools to ctx-taking functions** + +In `graph_toolkit.py`, apply the same pattern: + +```python +decision = ctx.deps.tool_budget.check("list_child_resources", "other") +if not decision.allowed: + assert decision.exhausted is not None + return decision.exhausted +``` + +Update all graph tools to `takes_ctx=True`. + +- [ ] **Step 3: Run import smoke checks** + +Run: + +```bash +uv run python - <<'PY' +from ergon_builtins.tools.research_rubrics_toolkit import ResearchRubricsToolkit +from ergon_builtins.tools.graph_toolkit import ResearchGraphToolkit +print(ResearchRubricsToolkit) +print(ResearchGraphToolkit) +PY +``` + +Expected: imports cleanly. + +--- + +## Task 5: Wire Budget Deps Into Current ResearchRubrics Workers + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py` +- Modify: `ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py` + +- [ ] **Step 1: Add policy imports** + +In both workers: + +```python +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetPolicy, + AgentToolBudgetState, +) +``` + +- [ ] **Step 2: Add a shared policy** + +Use the same generic policy in both files: + +```python +_TOOL_BUDGET_POLICY = AgentToolBudgetPolicy( + max_workflow_tool_calls=12, + max_other_tool_calls=12, + warning_at_remaining=3, +) +``` + +- [ ] **Step 3: Create deps per execution** + +In each `execute(...)`, before calling `super().execute(...)`: + +```python +self._agent_deps = AgentToolBudgetDeps( + AgentToolBudgetState(_TOOL_BUDGET_POLICY), +) +``` + +Add method: + +```python +def build_agent_deps(self, context: WorkerContext) -> AgentToolBudgetDeps: + return self._agent_deps +``` + +These worker instances are currently execution-scoped. If that changes later, move deps creation into a base-class execution context instead of storing on `self`. + +- [ ] **Step 4: Use budgeted workflow tool in manager** + +In `workflow_cli_react_worker.py`, change: + +```python +workflow_tool = make_workflow_cli_tool(...) +``` + +to: + +```python +workflow_tool = make_workflow_cli_tool(..., budgeted=True) +``` + +- [ ] **Step 5: Tighten prompts, but keep them generic** + +Researcher prompt: + +```text +You have a limited non-workflow tool budget. Gather enough context, then stop using tools and write final_output/report.md. If any tool returns TOOL_BUDGET_WARNING or TOOL_BUDGET_EXHAUSTED, immediately produce the best possible final report from the context already gathered. +``` + +Manager prompt: + +```text +For multi-step work, divide and conquer with focused subagents to manage context. Workflow calls are limited, so inspect deliberately, create focused children, avoid duplicate research, and converge after child resources are visible. If any tool returns TOOL_BUDGET_WARNING or TOOL_BUDGET_EXHAUSTED, stop polling/searching and produce the best possible final output from current context/resources. +``` + +- [ ] **Step 6: Run focused worker import** + +Run: + +```bash +uv run python - <<'PY' +from ergon_builtins.workers.research_rubrics.researcher_worker import ResearchRubricsResearcherWorker +from ergon_builtins.workers.research_rubrics.workflow_cli_react_worker import ResearchRubricsWorkflowCliReActWorker +print(ResearchRubricsResearcherWorker.type_slug) +print(ResearchRubricsWorkflowCliReActWorker.type_slug) +PY +``` + +Expected: prints both type slugs. + +--- + +## Task 6: Add Lightweight Rollout Reporting + +**Files:** +- Modify: `tests/real_llm/artifact_health.py` +- Modify: `tests/real_llm/rollout.py` + +- [ ] **Step 1: Count budget signals from existing events** + +In `artifact_health.py`, derive: + +```python +workflow_tool_calls +other_tool_calls +budget_exhausted +missing_final_report +``` + +Implementation rule: + +- If `tool_name == "workflow"`, increment `workflow_tool_calls`. +- Else if event type is `tool_call`, increment `other_tool_calls`. +- If any event payload has `status == "TOOL_BUDGET_EXHAUSTED"`, set `budget_exhausted=True`. +- If no resource path is `final_output/report.md`, set `missing_final_report=True`. + +- [ ] **Step 2: Show counters in rollout report** + +In `rollout.py`, add lines: + +```python +f"- workflow tool calls: {health.workflow_tool_calls}", +f"- other tool calls: {health.other_tool_calls}", +f"- budget exhausted: {health.budget_exhausted}", +f"- missing final report: {health.missing_final_report}", +``` + +- [ ] **Step 3: Run collection smoke** + +Run: + +```bash +uv run pytest tests/real_llm -q --collect-only +``` + +Expected: collection succeeds. + +--- + +## Task 7: Verify With One Real Sample + +**Files:** +- No new source files. + +- [ ] **Step 1: Run focused checks** + +Run: + +```bash +uv run pytest \ + tests/unit/state/test_workflow_cli_tool.py \ + tests/unit/workers/test_react_worker_contract.py \ + tests/unit/builtins/common/test_transcript_adapters.py \ + -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run lint on changed files** + +Run: + +```bash +uv run ruff check \ + ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py \ + ergon_builtins/ergon_builtins/workers/baselines/react_worker.py \ + ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py \ + ergon_builtins/ergon_builtins/tools/research_rubrics_toolkit.py \ + ergon_builtins/ergon_builtins/tools/graph_toolkit.py \ + ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py \ + ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py \ + tests/real_llm/artifact_health.py \ + tests/real_llm/rollout.py +``` + +Expected: `All checks passed!` + +- [ ] **Step 3: Rebuild and run one sample** + +Run: + +```bash +POSTGRES_PASSWORD=ergon_dev \ +TEST_HARNESS_SECRET=real-llm-secret \ +ENABLE_TEST_HARNESS=1 \ +ENABLE_SMOKE_FIXTURES=0 \ +ERGON_STARTUP_PLUGINS= \ +ERGON_LOGFIRE_PYDANTIC_AI=1 \ +ERGON_LOGFIRE_SERVICE_NAME=ergon-builtins \ +ERGON_LOGFIRE_ENVIRONMENT=real-llm \ +docker compose build api +``` + +Then: + +```bash +POSTGRES_PASSWORD=ergon_dev \ +TEST_HARNESS_SECRET=real-llm-secret \ +ENABLE_TEST_HARNESS=1 \ +ENABLE_SMOKE_FIXTURES=0 \ +ERGON_STARTUP_PLUGINS= \ +ERGON_LOGFIRE_PYDANTIC_AI=1 \ +ERGON_LOGFIRE_SERVICE_NAME=ergon-builtins \ +ERGON_LOGFIRE_ENVIRONMENT=real-llm \ +docker compose up -d --no-build --force-recreate --wait api +``` + +Then: + +```bash +ERGON_REAL_LLM=1 \ +ERGON_REAL_LLM_WORKER=researchrubrics-workflow-cli-react \ +ERGON_REAL_LLM_LIMIT=1 \ +ERGON_REAL_LLM_BUDGET_USD=5 \ +TEST_HARNESS_SECRET=real-llm-secret \ +ENABLE_TEST_HARNESS=1 \ +ENABLE_SMOKE_FIXTURES=0 \ +uv run pytest tests/real_llm/benchmarks/test_researchrubrics.py -q -s --assume-stack-up +``` + +Expected improvement: + +- no silent runaway loop. +- report shows `workflow tool calls <= 12`, or budget exhaustion is visible. +- report shows `other tool calls <= 12`, or budget exhaustion is visible. +- if the run fails, it fails with persisted transcript/error context that explains whether the budget was exhausted. + +--- + +## Notes + +- This is intentionally simpler than per-tool caps. No Exa-specific budget, no rubric-specific budget, no child-poll-specific budget. +- This still supports better prompt steering, but prompt steering is advisory. The two counters are enforcement. +- We should not add broad unit tests for every tool. Existing workflow tests, import smoke checks, lint, and the one-sample real rollout are enough for this change. +- Do not commit unless explicitly asked. + diff --git a/docs/superpowers/plans/2026-04-28-context-part-chunk-stream.md b/docs/superpowers/plans/2026-04-28-context-part-chunk-stream.md new file mode 100644 index 00000000..d4f00e7a --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-context-part-chunk-stream.md @@ -0,0 +1,1359 @@ +# Context Part Chunk Stream Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the parallel `GenerationTurn` and context-event payload model with one canonical context-part stream emitted by workers and enriched by core before persistence. + +**Architecture:** Define a single discriminated `ContextPart` union for things that appear in an LLM context/action stream: system prompts, user messages, assistant text, tool calls, tool results, and thinking. Workers yield `ContextPartChunk` values containing a `part` plus optional token metadata; core normalizes and enriches those chunks into persisted `RunContextEvent` rows with sequence, turn id, timestamps, worker key, and run/execution ids. Keep database rows flat enough for SQLModel/JSONB, but make API, dashboard, replay, and RL consumers use typed chunk/log schemas instead of duplicate payload unions. This is a clean-break migration: old `*Payload`, `GenerationTurn`, request/response part aliases, and old discriminator names must be gone by the final task. + +**Tech Stack:** Python 3.13, Pydantic v2 discriminated unions, SQLModel JSON columns, pytest, existing Ergon worker/runtime/persistence packages. + +--- + +## Source Of Truth + +The canonical worker-facing stream type should live in `ergon_core.core.generation` or a renamed module such as `ergon_core.core.context_stream`. To avoid a large import churn in the first slice, start in `ergon_core.core.generation`. + +Use these names: + +```python +ContextPart +ContextPartChunk +ContextPartChunkLog +WorkerYield +``` + +`ContextPart` is the only union for LLM context/action parts. + +`ContextPartChunk` is the de facto worker generator type. + +`ContextPartChunkLog` is the core-enriched durable event shape. It is not the database ORM model; it is the typed payload/envelope used when projecting a stored `RunContextEvent`. + +`RunContextEvent` remains the SQLModel row with JSON storage and relational ids. + +--- + +## Change Tree + +```text +ergon/ + ergon_core/ + ergon_core/ + core/ + generation.py # modify: canonical ContextPart/ContextPartChunk/ContextPartChunkLog + api/ + schemas.py # modify: typed REST context event payloads + runs.py # modify: project parsed chunk logs + dashboard/ + event_contracts.py # modify: dashboard context event payload uses chunk log + emitter.py # modify: emit parsed chunk logs + persistence/ + context/ + event_payloads.py # modify/delete duplicate payload union; no final old aliases + models.py # modify: validate JSON as ContextPartChunkLog + repository.py # modify: add persist_chunk enrichment; later delete persist_turn + rl/ + extraction.py # modify: consume chunk-log parts + runtime/ + services/ + task_execution_service.py # modify: persist worker chunks instead of turns + test_support/ + smoke_fixtures/ + smoke_base/ + leaf_base.py # modify: yield ContextPartChunk + recursive.py # modify: yield ContextPartChunk + worker_base.py # modify: yield ContextPartChunk + tests/ + unit/ + architecture/ + test_core_schema_sources.py # modify: guard single context part union + test_model_field_descriptions.py # modify: check chunk-log field descriptions + builtins/ + common/ + test_transcript_adapters.py # modify: assert chunk extraction/replay + dashboard/ + test_event_contract_types.py # modify: assert typed chunk-log dashboard payload + persistence/ + test_context_event_repository.py # modify: persist_chunk tests + state/ + test_context_part_stream.py # add: canonical part/chunk serialization tests + test_context_assembly.py # modify: replay from ContextPartChunkLog + test_generation_turn_build.py # modify/delete after GenerationTurn compatibility removal + workers/ + test_react_worker_contract.py # modify: worker yields chunks + ergon_builtins/ + ergon_builtins/ + common/ + llm_context/ + adapters/ + pydantic_ai.py # modify: build_chunks/build_new_chunks and replay chunk logs + workers/ + baselines/ + react_worker.py # modify: inspect ContextPartChunkLog.part + training_stub_worker.py # modify: yield ContextPartChunk + research_rubrics/ + researcher_worker.py # modify if still yielding GenerationTurn + workflow_cli_react_worker.py # modify if still yielding GenerationTurn +``` + +--- + +## File Structure + +**Modify:** +- `ergon_core/ergon_core/core/generation.py` — replace request/response-specific part model as the canonical context stream model while preserving temporary aliases during migration. +- `ergon_core/ergon_core/core/persistence/context/event_payloads.py` — replace the duplicate payload union with canonical context-event type exports only; do not keep old payload aliases in the final state. +- `ergon_core/ergon_core/core/persistence/context/models.py` — validate stored JSON as `ContextPartChunkLog` or the log payload shape. +- `ergon_core/ergon_core/core/persistence/context/repository.py` — replace `persist_turn()` decomposition with `persist_chunk()` enrichment; keep a temporary `persist_turn()` adapter if needed for staged migration. +- `ergon_core/ergon_core/core/api/schemas.py` — type REST context-event DTOs with `ContextPartChunkLog` instead of `dict[str, Any]`. +- `ergon_core/ergon_core/core/api/runs.py` — project stored context events through typed log validation. +- `ergon_core/ergon_core/core/dashboard/event_contracts.py` — use the same typed log schema as REST for context events. +- `ergon_core/ergon_core/core/dashboard/emitter.py` — emit typed enriched context logs. +- `ergon_core/ergon_core/core/rl/extraction.py` — read `event.part` instead of payload-specific classes. +- `ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py` — convert PydanticAI messages into `ContextPartChunk` streams and replay logs back into PydanticAI messages. +- `ergon_builtins/ergon_builtins/workers/baselines/react_worker.py` — consume the new typed context stream. +- `ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py` — yield chunks instead of `GenerationTurn`. +- `ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/*.py` — yield chunks in smoke workers. + +**Tests:** +- `tests/unit/state/test_context_part_stream.py` — new focused tests for canonical union and chunk serialization. +- `tests/unit/persistence/test_context_event_repository.py` — rewrite around `persist_chunk()`. +- `tests/unit/builtins/common/test_transcript_adapters.py` — update PydanticAI adapter tests to assert chunk/log behavior. +- `tests/unit/state/test_context_assembly.py` — update replay tests around `ContextPartChunkLog`. +- `tests/unit/architecture/test_core_schema_sources.py` — add architecture guard against reintroducing duplicate context payload unions. +- Existing focused tests: `tests/unit/state/test_generation_turn_build.py`, `tests/unit/workers/test_react_worker_contract.py`, `tests/unit/dashboard/test_event_contract_types.py`, `tests/unit/architecture/test_model_field_descriptions.py`. + +--- + +### Task 1: Introduce Canonical Context Parts + +**Files:** +- Modify: `ergon_core/ergon_core/core/generation.py` +- Create: `tests/unit/state/test_context_part_stream.py` + +- [ ] **Step 1: Write failing tests for the canonical part union** + +Create `tests/unit/state/test_context_part_stream.py` with: + +```python +from pydantic import TypeAdapter + +from ergon_core.core.generation import ( + AssistantTextPart, + ContextPart, + ContextPartChunk, + ContextPartChunkLog, + SystemPromptPart, + ThinkingPart, + TokenLogprob, + ToolCallPart, + ToolResultPart, + UserMessagePart, +) + + +def test_context_part_discriminates_all_part_kinds() -> None: + adapter = TypeAdapter(ContextPart) + + cases = [ + SystemPromptPart(content="sys"), + UserMessagePart(content="hi"), + AssistantTextPart(content="hello"), + ToolCallPart(tool_call_id="call-1", tool_name="search", args={"q": "x"}), + ToolResultPart(tool_call_id="call-1", tool_name="search", content="ok"), + ThinkingPart(content="reasoning"), + ] + + for part in cases: + dumped = part.model_dump(mode="json") + parsed = adapter.validate_python(dumped) + assert parsed == part + + +def test_context_part_chunk_wraps_part_with_optional_token_metadata() -> None: + chunk = ContextPartChunk( + part=AssistantTextPart(content="answer"), + token_ids=[1, 2], + logprobs=[TokenLogprob(token="answer", logprob=-0.1)], + ) + + dumped = chunk.model_dump(mode="json") + + assert dumped["part"]["part_kind"] == "assistant_text" + assert dumped["token_ids"] == [1, 2] + assert dumped["logprobs"][0]["token"] == "answer" + + +def test_context_part_chunk_log_adds_core_enrichment() -> None: + log = ContextPartChunkLog( + part=ThinkingPart(content="hmm"), + sequence=7, + worker_binding_key="researcher", + turn_id="turn-1", + token_ids=None, + logprobs=None, + ) + + dumped = log.model_dump(mode="json") + + assert dumped["part"]["part_kind"] == "thinking" + assert dumped["sequence"] == 7 + assert dumped["worker_binding_key"] == "researcher" + assert dumped["turn_id"] == "turn-1" +``` + +- [ ] **Step 2: Run the failing tests** + +Run: + +```bash +pytest tests/unit/state/test_context_part_stream.py -v +``` + +Expected: FAIL because `AssistantTextPart`, `UserMessagePart`, `ToolResultPart`, `ContextPartChunk`, and `ContextPartChunkLog` do not exist yet. + +- [ ] **Step 3: Implement canonical context stream types** + +Modify `ergon_core/ergon_core/core/generation.py` to define the canonical names. This task may keep request/response subset aliases only if needed to keep the next migration task small; those aliases must be deleted in Task 7 before the plan is complete. + +```python +"""Core model context-stream types. + +These types are used by worker APIs, transcript adapters, persistence, replay, +and RL extraction. Keep them in core so persistence can import them without +loading ``ergon_core.api``. +""" + +from datetime import datetime +from typing import Annotated, Any, Literal + +from ergon_core.core.json_types import JsonObject +from pydantic import BaseModel, Field + + +class TokenLogprob(BaseModel): + """Per-token log probability from the serving backend.""" + + model_config = {"frozen": True} + + token: str + logprob: float + top_logprobs: list[JsonObject] = Field(default_factory=list) + + +class SystemPromptPart(BaseModel): + model_config = {"frozen": True} + part_kind: Literal["system_prompt"] = "system_prompt" + content: str + + +class UserMessagePart(BaseModel): + model_config = {"frozen": True} + part_kind: Literal["user_message"] = "user_message" + content: str + + +class AssistantTextPart(BaseModel): + model_config = {"frozen": True} + part_kind: Literal["assistant_text"] = "assistant_text" + content: str + + +class ToolCallPart(BaseModel): + model_config = {"frozen": True} + part_kind: Literal["tool_call"] = "tool_call" + tool_name: str + tool_call_id: str + args: dict[str, Any] # slopcop: ignore[no-typing-any] + + +class ToolResultPart(BaseModel): + model_config = {"frozen": True} + part_kind: Literal["tool_result"] = "tool_result" + tool_call_id: str + tool_name: str + content: str + is_error: bool = False + + +class ThinkingPart(BaseModel): + model_config = {"frozen": True} + part_kind: Literal["thinking"] = "thinking" + content: str + + +ContextPart = Annotated[ + SystemPromptPart + | UserMessagePart + | AssistantTextPart + | ToolCallPart + | ToolResultPart + | ThinkingPart, + Field(discriminator="part_kind"), +] + + +class ContextPartChunk(BaseModel): + """One worker-emitted context/action stream item. + + Core adds run/execution/sequence/timing metadata before persistence. + """ + + model_config = {"frozen": True} + + part: ContextPart + token_ids: list[int] | None = None + logprobs: list[TokenLogprob] | None = None + + +class ContextPartChunkLog(ContextPartChunk): + """Core-enriched context stream item suitable for API/dashboard projection.""" + + sequence: int + worker_binding_key: str + turn_id: str | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + policy_version: str | None = None + + +WorkerYield = ContextPartChunk + +# Temporary migration-only aliases. Task 7 must remove these before completion. +UserPromptPart = UserMessagePart +TextPart = AssistantTextPart +ToolReturnPart = ToolResultPart + +ModelRequestPart = Annotated[ + SystemPromptPart | UserMessagePart | ToolResultPart, + Field(discriminator="part_kind"), +] +ModelResponsePart = Annotated[ + AssistantTextPart | ToolCallPart | ThinkingPart, + Field(discriminator="part_kind"), +] + + +class GenerationTurn(BaseModel): + """Deprecated: use ContextPartChunk streams instead.""" + + model_config = {"frozen": True} + + messages_in: list[ModelRequestPart] = Field(default_factory=list) + response_parts: list[ModelResponsePart] = Field(default_factory=list) + tool_results: list[ToolResultPart] = Field(default_factory=list) + turn_token_ids: list[int] | None = None + turn_logprobs: list[TokenLogprob] | None = None + policy_version: str | None = None + started_at: datetime | None = None + completed_at: datetime | None = None +``` + +- [ ] **Step 4: Run the focused tests** + +Run: + +```bash +pytest tests/unit/state/test_context_part_stream.py -v +``` + +Expected: PASS. + +- [ ] **Step 5: Run generation-related tests to expose compatibility fallout** + +Run: + +```bash +pytest tests/unit/state/test_generation_turn_build.py tests/unit/builtins/common/test_transcript_adapters.py -v +``` + +Expected: likely FAIL because existing tests assert old discriminator values such as `tool-call` and old constructor names such as `ToolReturnPart`. + +--- + +### Task 2: Replace Payload Union With Enriched Chunk Log + +**Files:** +- Modify: `ergon_core/ergon_core/core/persistence/context/event_payloads.py` +- Modify: `ergon_core/ergon_core/core/persistence/context/models.py` +- Modify: `tests/unit/architecture/test_model_field_descriptions.py` + +- [ ] **Step 1: Write failing compatibility tests for typed log payload validation** + +Update or add tests that assert the context event row validates its JSON as `ContextPartChunkLog`: + +```python +from ergon_core.core.generation import AssistantTextPart, ContextPartChunkLog +from ergon_core.core.persistence.context.models import RunContextEvent + + +def test_run_context_event_parsed_payload_is_context_part_chunk_log() -> None: + log = ContextPartChunkLog( + part=AssistantTextPart(content="hello"), + sequence=3, + worker_binding_key="worker-a", + turn_id="turn-1", + ) + event = RunContextEvent( + run_id="00000000-0000-0000-0000-000000000001", + task_execution_id="00000000-0000-0000-0000-000000000002", + worker_binding_key="worker-a", + sequence=3, + event_type="assistant_text", + payload=log.model_dump(mode="json"), + ) + + parsed = event.parsed_payload() + + assert isinstance(parsed, ContextPartChunkLog) + assert parsed.part == AssistantTextPart(content="hello") +``` + +If UUID strings are not accepted by SQLModel in this test, use `uuid.UUID(...)` values instead. + +- [ ] **Step 2: Run the failing test** + +Run: + +```bash +pytest tests/unit/persistence/test_context_event_repository.py::test_run_context_event_parsed_payload_is_context_part_chunk_log -v +``` + +Expected: FAIL until `RunContextEvent.parsed_payload()` validates the new log shape. + +- [ ] **Step 3: Collapse `event_payloads.py` into canonical exports** + +Modify `ergon_core/ergon_core/core/persistence/context/event_payloads.py` so the canonical payload is `ContextPartChunkLog`. Do not define `SystemPromptPayload`, `UserMessagePayload`, `AssistantTextPayload`, `ToolCallPayload`, `ToolResultPayload`, or `ThinkingPayload`; callers must migrate to `ContextPartChunkLog.part` and the canonical part classes. + +```python +"""Typed context event payload exports. + +The canonical context payload is an enriched ContextPartChunkLog. Event-specific +payload classes were removed in favor of ContextPartChunkLog.part. +""" + +from typing import Literal + +from ergon_core.core.generation import ( + ContextPart, + ContextPartChunk, + ContextPartChunkLog, +) + +ContextEventType = Literal[ + "system_prompt", + "user_message", + "assistant_text", + "tool_call", + "tool_result", + "thinking", +] + +ContextEventPayload = ContextPartChunkLog +``` + +- [ ] **Step 4: Update `RunContextEvent` validation** + +Modify `ergon_core/ergon_core/core/persistence/context/models.py`: + +```python +from ergon_core.core.generation import ContextPartChunkLog +from pydantic import TypeAdapter + +_PAYLOAD_ADAPTER: TypeAdapter[ContextPartChunkLog] = TypeAdapter(ContextPartChunkLog) + + +class RunContextEvent(SQLModel, table=True): + ... + + def parsed_payload(self) -> ContextPartChunkLog: + return _PAYLOAD_ADAPTER.validate_python(self.payload) +``` + +Keep `event_type: str` and `payload: dict[str, Any]` on the SQLModel row because the database stores JSON and indexes `event_type`. + +- [ ] **Step 5: Replace field-description architecture tests** + +Update `tests/unit/architecture/test_model_field_descriptions.py` to check descriptions on `ContextPartChunkLog` if the project requires descriptions for public fields. Do not keep tests against the old payload classes once they are aliases. + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +pytest tests/unit/persistence/test_context_event_repository.py tests/unit/architecture/test_model_field_descriptions.py -v +``` + +Expected: repository tests still fail until Task 3 replaces `persist_turn()` behavior. + +--- + +### Task 3: Persist Worker Chunks With Core Enrichment + +**Files:** +- Modify: `ergon_core/ergon_core/core/persistence/context/repository.py` +- Modify: `tests/unit/persistence/test_context_event_repository.py` + +- [ ] **Step 1: Write repository tests for `persist_chunk()`** + +Replace turn-oriented tests with chunk-oriented tests: + +```python +from uuid import uuid4 + +from ergon_core.core.generation import ( + AssistantTextPart, + ContextPartChunk, + ThinkingPart, + ToolCallPart, + ToolResultPart, + UserMessagePart, +) + + +async def test_persist_chunk_records_prompt_and_model_output_in_order(session): + repo = ContextEventRepository() + run_id = uuid4() + execution_id = uuid4() + + await repo.persist_chunk( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker-a", + chunk=ContextPartChunk(part=UserMessagePart(content="question")), + ) + await repo.persist_chunk( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker-a", + chunk=ContextPartChunk(part=ThinkingPart(content="think")), + ) + await repo.persist_chunk( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker-a", + chunk=ContextPartChunk(part=AssistantTextPart(content="answer")), + ) + + events = repo.get_for_execution(session, execution_id) + + assert [event.sequence for event in events] == [0, 1, 2] + assert [event.event_type for event in events] == [ + "user_message", + "thinking", + "assistant_text", + ] + assert events[1].parsed_payload().turn_id == events[2].parsed_payload().turn_id + + +async def test_persist_chunk_tool_result_closes_current_turn(session): + repo = ContextEventRepository() + run_id = uuid4() + execution_id = uuid4() + + await repo.persist_chunk( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker-a", + chunk=ContextPartChunk( + part=ToolCallPart(tool_call_id="call-1", tool_name="search", args={"q": "x"}) + ), + ) + await repo.persist_chunk( + session, + run_id=run_id, + execution_id=execution_id, + worker_binding_key="worker-a", + chunk=ContextPartChunk( + part=ToolResultPart(tool_call_id="call-1", tool_name="search", content="ok") + ), + ) + + events = repo.get_for_execution(session, execution_id) + + assert [event.event_type for event in events] == ["tool_call", "tool_result"] + assert events[0].parsed_payload().turn_id is not None + assert events[1].parsed_payload().turn_id is None +``` + +Adjust fixture names to match the existing `test_context_event_repository.py` session fixture. + +- [ ] **Step 2: Run repository tests to verify failure** + +Run: + +```bash +pytest tests/unit/persistence/test_context_event_repository.py -v +``` + +Expected: FAIL because `persist_chunk()` does not exist. + +- [ ] **Step 3: Implement event type derivation** + +In `ergon_core/ergon_core/core/persistence/context/repository.py`, add: + +```python +from ergon_core.core.generation import ( + AssistantTextPart, + ContextPartChunk, + ContextPartChunkLog, + SystemPromptPart, + ThinkingPart, + ToolCallPart, + ToolResultPart, + UserMessagePart, +) + + +def _event_type_for_part(part: ContextPart) -> str: + return part.part_kind +``` + +If type checkers object to `ContextPart` as an `Annotated` alias in the helper signature, use the explicit union type or accept `object` and narrow via `isinstance`. + +- [ ] **Step 4: Implement turn-id state machine** + +Add private state to the repository: + +```python +def __init__(self) -> None: + self._listeners: list[Callable[[RunContextEvent], Awaitable[None]]] = [] + self._sequence_counters: dict[UUID, int] = {} + self._active_turn_ids: dict[UUID, str] = {} +``` + +Add helpers: + +```python +def _turn_id_for_chunk(self, execution_id: UUID, chunk: ContextPartChunk) -> str | None: + part = chunk.part + if isinstance(part, (AssistantTextPart, ThinkingPart, ToolCallPart)): + turn_id = self._active_turn_ids.get(execution_id) + if turn_id is None: + turn_id = str(uuid4()) + self._active_turn_ids[execution_id] = turn_id + return turn_id + if isinstance(part, ToolResultPart): + self._active_turn_ids.pop(execution_id, None) + return None + if isinstance(part, (SystemPromptPart, UserMessagePart)): + return None + return None +``` + +This deliberately associates `thinking`, `assistant_text`, and `tool_call` chunks emitted contiguously with the same model-output turn. A following `tool_result` closes the active turn. + +- [ ] **Step 5: Implement `persist_chunk()`** + +Add: + +```python +async def persist_chunk( + self, + session: Session, + *, + run_id: UUID, + execution_id: UUID, + worker_binding_key: str, + chunk: ContextPartChunk, +) -> RunContextEvent: + seq = self._next_sequence(execution_id) + turn_id = self._turn_id_for_chunk(execution_id, chunk) + event_type = chunk.part.part_kind + now = datetime.now(UTC) + payload = ContextPartChunkLog( + part=chunk.part, + token_ids=chunk.token_ids, + logprobs=chunk.logprobs, + sequence=seq, + worker_binding_key=worker_binding_key, + turn_id=turn_id, + started_at=now, + completed_at=now, + ) + event = self._make_event( + run_id, + execution_id, + worker_binding_key, + seq, + payload, + started_at=payload.started_at, + completed_at=payload.completed_at, + policy_version=payload.policy_version, + ) + self._sequence_counters[execution_id] = seq + 1 + + session.add(event) + session.commit() + + for listener in self._listeners: + try: + await listener(event) + except Exception: # slopcop: ignore[no-broad-except] + logger.warning("Context event listener failed", exc_info=True) + + return event +``` + +Update `_make_event()` to accept `payload: ContextPartChunkLog` and store `payload.model_dump(mode="json")`. + +- [ ] **Step 6: Keep a temporary `persist_turn()` adapter** + +During migration only, keep `persist_turn()` by decomposing old `GenerationTurn` into chunks: + +```python +async def persist_turn(..., turn: GenerationTurn) -> list[RunContextEvent]: + events: list[RunContextEvent] = [] + for part in turn.messages_in: + events.append(await self.persist_chunk(..., chunk=ContextPartChunk(part=part))) + for part in turn.response_parts: + events.append( + await self.persist_chunk( + ..., + chunk=ContextPartChunk( + part=part, + token_ids=turn.turn_token_ids, + logprobs=turn.turn_logprobs, + ), + ) + ) + for part in turn.tool_results: + events.append(await self.persist_chunk(..., chunk=ContextPartChunk(part=part))) + return events +``` + +This keeps old workers running while the execution service migrates to chunks. + +- [ ] **Step 7: Run persistence tests** + +Run: + +```bash +pytest tests/unit/persistence/test_context_event_repository.py -v +``` + +Expected: PASS after updating any old assertions to inspect `event.parsed_payload().part`. + +--- + +### Task 4: Migrate PydanticAI Adapter To Chunk Streams + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py` +- Modify: `tests/unit/builtins/common/test_transcript_adapters.py` +- Modify: `tests/unit/state/test_generation_turn_build.py` +- Modify: `tests/unit/state/test_context_assembly.py` + +- [ ] **Step 1: Write adapter tests for chunk extraction** + +Update `tests/unit/builtins/common/test_transcript_adapters.py` so PydanticAI transcript extraction returns chunks: + +```python +def test_text_and_thinking_are_context_part_chunks() -> None: + adapter = PydanticAITranscriptAdapter() + + chunks = adapter.build_chunks( + [ + ModelRequest(parts=[UserPromptPart(content="hard question")]), + ModelResponse( + parts=[ + ThinkingPart(content="let me reason"), + TextPart(content="answer"), + ] + ), + ] + ) + + assert [chunk.part.part_kind for chunk in chunks] == [ + "user_message", + "thinking", + "assistant_text", + ] +``` + +Add a tool-call/tool-result test: + +```python +def test_tool_call_and_return_become_context_part_chunks() -> None: + adapter = PydanticAITranscriptAdapter() + + chunks = adapter.build_chunks( + [ + ModelRequest(parts=[UserPromptPart(content="search")]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name="search", + tool_call_id="call-1", + args={"query": "ergon"}, + ) + ] + ), + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="search", + tool_call_id="call-1", + content={"result": "found"}, + ) + ] + ), + ] + ) + + assert [chunk.part.part_kind for chunk in chunks] == [ + "user_message", + "tool_call", + "tool_result", + ] +``` + +- [ ] **Step 2: Run adapter tests to verify failure** + +Run: + +```bash +pytest tests/unit/builtins/common/test_transcript_adapters.py -v +``` + +Expected: FAIL because `build_chunks()` does not exist. + +- [ ] **Step 3: Implement `build_chunks()` and `build_new_chunks()`** + +In `ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py`, add methods parallel to the existing turn methods: + +```python +def build_chunks( + self, + transcript: list[ModelMessage], + *, + flush_pending: bool = True, +) -> list[ContextPartChunk]: + return _build_chunks_from_transcript(transcript, flush_pending=flush_pending) + + +def build_new_chunks( + self, + transcript: list[ModelMessage], + cursor: TranscriptTurnCursor, + *, + flush_pending: bool = False, +) -> list[ContextPartChunk]: + chunks = _build_chunks_from_transcript(transcript, flush_pending=flush_pending) + new_chunks = chunks[cursor.emitted_turn_count :] + cursor.emitted_turn_count = len(chunks) + return new_chunks +``` + +Rename `TranscriptTurnCursor.emitted_turn_count` to `emitted_chunk_count` only if the migration can update all callers in one task. Otherwise leave the field name temporarily and add a follow-up cleanup task. + +- [ ] **Step 4: Implement PydanticAI part conversion** + +Replace old `_extract_request_parts`, `_extract_response_parts`, and `_extract_tool_results` internals with chunk builders: + +```python +def _chunks_from_request(request: ModelRequest) -> list[ContextPartChunk]: + chunks: list[ContextPartChunk] = [] + for part in request.parts: + if isinstance(part, PydanticSystemPromptPart): + chunks.append(ContextPartChunk(part=SystemPromptPart(content=part.content))) + elif isinstance(part, PydanticUserPromptPart) and isinstance(part.content, str): + chunks.append(ContextPartChunk(part=UserMessagePart(content=part.content))) + elif isinstance(part, PydanticToolReturnPart): + chunks.append( + ContextPartChunk( + part=ToolResultPart( + tool_call_id=part.tool_call_id, + tool_name=part.tool_name, + content=_serialize_tool_content(part.content), + ) + ) + ) + return chunks + + +def _chunks_from_response(response: ModelResponse) -> list[ContextPartChunk]: + logprobs = extract_logprobs(response) + chunks: list[ContextPartChunk] = [] + for part in response.parts: + if isinstance(part, PydanticTextPart): + chunks.append( + ContextPartChunk(part=AssistantTextPart(content=part.content), logprobs=logprobs) + ) + logprobs = None + elif isinstance(part, PydanticToolCallPart): + chunks.append( + ContextPartChunk( + part=ToolCallPart( + tool_name=part.tool_name, + tool_call_id=part.tool_call_id, + args=part.args_as_dict(), + ), + logprobs=logprobs, + ) + ) + logprobs = None + elif isinstance(part, PydanticThinkingPart): + chunks.append( + ContextPartChunk(part=ThinkingPart(content=part.content), logprobs=logprobs) + ) + logprobs = None + return chunks +``` + +Only attach turn-level logprobs to the first model-output chunk. This matches the current persisted behavior where sibling events omit the shared token stream after the first model-output event. + +- [ ] **Step 5: Implement replay from chunk logs** + +Update `assemble_replay()` to consume `RunContextEvent.parsed_payload()` as `ContextPartChunkLog`, then switch on `log.part`. + +```python +payload = event.parsed_payload() +part = payload.part +``` + +Map: +- `SystemPromptPart` -> `PydanticSystemPromptPart` +- `UserMessagePart` -> `PydanticUserPromptPart` +- `ToolResultPart` -> `PydanticToolReturnPart` +- `ThinkingPart` -> `PydanticThinkingPart` +- `AssistantTextPart` -> `PydanticTextPart` +- `ToolCallPart` -> `PydanticToolCallPart` + +- [ ] **Step 6: Keep old adapter methods as wrappers** + +Keep `build_turns()` and `build_new_turns()` temporarily by grouping chunks into a deprecated `GenerationTurn` only if old callers still exist at this point. Add comments marking them as migration-only. Task 7 must delete these wrappers; the final codebase must not expose the old turn API. + +- [ ] **Step 7: Run adapter and replay tests** + +Run: + +```bash +pytest tests/unit/builtins/common/test_transcript_adapters.py tests/unit/state/test_context_assembly.py tests/unit/state/test_generation_turn_build.py -v +``` + +Expected: PASS after old tests are rewritten or any migration-only wrappers are correct. These wrappers are not allowed to remain after Task 7. + +--- + +### Task 5: Migrate Worker Interface And Execution Persistence + +**Files:** +- Modify: `ergon_core/ergon_core/core/runtime/services/task_execution_service.py` +- Modify: `ergon_core/ergon_core/api/results.py` +- Modify: worker base API files that type `execute()` return values. +- Modify: `ergon_builtins/ergon_builtins/workers/baselines/react_worker.py` +- Modify: `ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py` +- Modify: smoke fixture workers under `ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/` +- Modify: `tests/unit/workers/test_react_worker_contract.py` +- Modify: `tests/unit/state/test_research_rubrics_workers.py` + +- [ ] **Step 1: Find all `AsyncGenerator[GenerationTurn` callers** + +Run: + +```bash +rg "AsyncGenerator\\[GenerationTurn|GenerationTurn" ergon_core ergon_builtins tests -n +``` + +Expected: a finite list including builtins workers, smoke fixtures, test support, and execution persistence. + +- [ ] **Step 2: Update worker API type hints** + +Replace worker `execute()` signatures from: + +```python +) -> AsyncGenerator[GenerationTurn, None]: +``` + +to: + +```python +) -> AsyncGenerator[ContextPartChunk, None]: +``` + +Import `ContextPartChunk` from `ergon_core.core.generation`. + +- [ ] **Step 3: Update task execution persistence loop** + +In `task_execution_service.py`, replace the turn persistence call: + +```python +async for turn in worker.execute(task, context=context): + await context_event_repository.persist_turn( + session, + run_id=run_id, + execution_id=execution.id, + worker_binding_key=worker_binding_key, + turn=turn, + ) +``` + +with: + +```python +async for chunk in worker.execute(task, context=context): + await context_event_repository.persist_chunk( + session, + run_id=run_id, + execution_id=execution.id, + worker_binding_key=worker_binding_key, + chunk=chunk, + ) +``` + +Keep exact local variable names consistent with the existing file. + +- [ ] **Step 4: Update simple text-yielding workers** + +For smoke workers that currently yield: + +```python +yield GenerationTurn(response_parts=[TextPart(content="...")]) +``` + +replace with: + +```python +yield ContextPartChunk(part=AssistantTextPart(content="...")) +``` + +For user prompt setup chunks, emit: + +```python +yield ContextPartChunk(part=UserMessagePart(content="...")) +``` + +Only emit prompt chunks if the worker previously included them in `messages_in`; do not invent additional prompt events. + +- [ ] **Step 5: Update `training_stub_worker.py`** + +Replace synthetic `GenerationTurn` creation with chunk lists: + +```python +chunks: list[ContextPartChunk] = [] +chunks.append(ContextPartChunk(part=UserMessagePart(content=f"Task: Synthetic task {task_slug}"))) +chunks.append( + ContextPartChunk( + part=ToolCallPart( + tool_name="stub_tool", + tool_call_id=f"call_{i}", + args={"turn": i, "task": task_slug}, + ), + logprobs=logprobs, + ) +) +chunks.append( + ContextPartChunk( + part=ToolResultPart( + tool_call_id=f"call_{i}", + tool_name="stub_tool", + content=f"Tool result for turn {i} of {task_slug}", + ) + ) +) +``` + +For final assistant output: + +```python +ContextPartChunk( + part=AssistantTextPart(content=f"Synthetic response turn {i}"), + logprobs=logprobs, +) +``` + +- [ ] **Step 6: Update `react_worker.py`** + +Where the worker previously handled `GenerationTurn` outputs or inspected payload classes, switch to chunk/log parts: + +```python +payload = event.parsed_payload() +part = payload.part +if isinstance(part, AssistantTextPart): + ... +``` + +For final assistant message extraction, replace `AssistantTextPayload` checks with `AssistantTextPart`. + +- [ ] **Step 7: Run worker contract tests** + +Run: + +```bash +pytest tests/unit/workers/test_react_worker_contract.py tests/unit/state/test_research_rubrics_workers.py -v +``` + +Expected: PASS after signatures and assertions are migrated. + +--- + +### Task 6: Update REST, Dashboard, And RL Consumers + +**Files:** +- Modify: `ergon_core/ergon_core/core/api/schemas.py` +- Modify: `ergon_core/ergon_core/core/api/runs.py` +- Modify: `ergon_core/ergon_core/core/dashboard/event_contracts.py` +- Modify: `ergon_core/ergon_core/core/dashboard/emitter.py` +- Modify: `ergon_core/ergon_core/core/rl/extraction.py` +- Modify: dashboard generated contracts if this repo checks them in. +- Modify: `tests/unit/dashboard/test_event_contract_types.py` + +- [ ] **Step 1: Type REST context event DTOs with chunk logs** + +Modify `RunContextEventDto`: + +```python +from ergon_core.core.generation import ContextPartChunkLog +from ergon_core.core.persistence.context.event_payloads import ContextEventType + + +class RunContextEventDto(CamelModel): + id: str + task_execution_id: str + task_node_id: str + worker_binding_key: str + sequence: int + event_type: ContextEventType + payload: ContextPartChunkLog + created_at: str + started_at: str | None = None + completed_at: str | None = None +``` + +- [ ] **Step 2: Project typed payloads in REST snapshots** + +In `_context_events_by_task()`, change: + +```python +payload=event.payload, +``` + +to: + +```python +payload=event.parsed_payload(), +``` + +Keep `event_type=cast(ContextEventType, event.event_type)` if type checking requires it. + +- [ ] **Step 3: Type dashboard event contracts with the same payload** + +In `event_contracts.py`, ensure: + +```python +payload: ContextPartChunkLog +``` + +instead of the old `ContextEventPayload` union alias if that alias is still confusing. + +- [ ] **Step 4: Update dashboard emitter payload validation** + +In `emitter.py`, validate as: + +```python +payload=event.parsed_payload() +``` + +instead of constructing a separate TypeAdapter in the emitter. + +- [ ] **Step 5: Update RL extraction** + +Change event handling from payload-class checks to part-class checks: + +```python +payload = event.parsed_payload() +part = payload.part + +if isinstance(part, (SystemPromptPart, UserMessagePart)): + ... +elif isinstance(part, (AssistantTextPart, ToolCallPart, ThinkingPart)): + token_ids = _get_token_ids(payload, tokenizer) +elif isinstance(part, ToolResultPart): + result_tokens = tokenizer.encode(str(part.content)) +``` + +Update `_get_token_ids()` to accept `ContextPartChunkLog` and inspect `payload.part`. + +- [ ] **Step 6: Run REST/dashboard/RL tests** + +Run: + +```bash +pytest tests/unit/dashboard/test_event_contract_types.py tests/unit/state/test_context_assembly.py tests/unit/persistence/test_context_event_repository.py -v +``` + +Expected: PASS after DTOs and consumers use `ContextPartChunkLog`. + +--- + +### Task 7: Add Architecture Guards And Remove Deprecated Turn API + +**Files:** +- Modify: `tests/unit/architecture/test_core_schema_sources.py` +- Modify: `ergon_core/ergon_core/core/generation.py` +- Modify: any remaining files found by `rg`. + +- [ ] **Step 1: Add architecture guard against duplicate context payload unions** + +Add to `tests/unit/architecture/test_core_schema_sources.py`: + +```python +from pathlib import Path + + +def test_context_stream_has_single_discriminated_part_union() -> None: + root = Path(__file__).resolve().parents[3] + generation = root / "ergon_core" / "ergon_core" / "core" / "generation.py" + event_payloads = ( + root + / "ergon_core" + / "ergon_core" + / "core" + / "persistence" + / "context" + / "event_payloads.py" + ) + + generation_text = generation.read_text() + event_payloads_text = event_payloads.read_text() + + assert "ContextPart = Annotated[" in generation_text + assert "SystemPromptPayload |" not in event_payloads_text + assert "AssistantTextPayload |" not in event_payloads_text + assert "ToolCallPayload |" not in event_payloads_text +``` + +- [ ] **Step 2: Run the architecture test** + +Run: + +```bash +pytest tests/unit/architecture/test_core_schema_sources.py -v +``` + +Expected: PASS only after `event_payloads.py` no longer owns a duplicate payload union. + +- [ ] **Step 3: Remove deprecated `GenerationTurn` compatibility** + +Run: + +```bash +rg "GenerationTurn|ModelRequestPart|ModelResponsePart|ToolReturnPart|TextPart|UserPromptPart" ergon_core ergon_builtins tests -n +``` + +Remove remaining old names where possible. Keep `TextPart` only when it refers to `pydantic_ai.messages.TextPart`, and alias it as `PydanticTextPart` in imports to avoid confusion. + +- [ ] **Step 4: Delete compatibility aliases** + +From `generation.py`, remove: + +```python +UserPromptPart = UserMessagePart +TextPart = AssistantTextPart +ToolReturnPart = ToolResultPart +ModelRequestPart = ... +ModelResponsePart = ... +class GenerationTurn(...) +``` + +Only do this once `rg` confirms no production caller depends on those names. + +- [ ] **Step 5: Verify no old payload classes or aliases exist in `event_payloads.py`** + +Run: + +```bash +rg "SystemPromptPayload|UserMessagePayload|AssistantTextPayload|ToolCallPayload|ToolResultPayload|ThinkingPayload" ergon_core ergon_builtins tests -n +``` + +Expected: no production matches. Test matches should be migrated to `ContextPartChunkLog` and canonical part classes. + +Confirm `event_payloads.py` does not define or export: + +```python +SystemPromptPayload +UserMessagePayload +AssistantTextPayload +ToolCallPayload +ToolResultPayload +ThinkingPayload +``` + +Keep: + +```python +ContextEventType +ContextEventPayload = ContextPartChunkLog +``` + +or rename `ContextEventPayload` to `ContextPartChunkLog` everywhere if the alias is no longer useful. + +- [ ] **Step 6: Run full focused suite** + +Run: + +```bash +pytest \ + tests/unit/state/test_context_part_stream.py \ + tests/unit/persistence/test_context_event_repository.py \ + tests/unit/builtins/common/test_transcript_adapters.py \ + tests/unit/state/test_context_assembly.py \ + tests/unit/workers/test_react_worker_contract.py \ + tests/unit/dashboard/test_event_contract_types.py \ + tests/unit/architecture/test_core_schema_sources.py \ + -v +``` + +Expected: PASS. + +- [ ] **Step 7: Run broader unit smoke** + +Run: + +```bash +pytest tests/unit -q +``` + +Expected: PASS, or only unrelated pre-existing failures. Investigate any failures mentioning context events, generation turns, workers, dashboard contracts, replay, or RL extraction. + +--- + +## Migration Notes + +This is a schema/API clean break. Do not preserve backwards compatibility with the old schemas in the final state. + +Temporary adapters are allowed only inside intermediate tasks to make the migration reviewable: +- `GenerationTurn` can exist only until worker execution is moved to chunks. +- request/response subset aliases can exist only until all worker and adapter callers move to `ContextPartChunk`. +- old `*Payload` event classes should not be reintroduced as aliases; migrate those callers directly to `ContextPartChunkLog.part`. + +After Task 7, the only canonical stream type should be `ContextPart`, the worker generator type should be `ContextPartChunk`, and the enriched log type should be `ContextPartChunkLog`. + +Do not add a second new union in `event_payloads.py`. Do not leave compatibility exports for the old payload classes. Either outcome recreates the drift this plan is removing. + +--- + +## Self-Review + +**Spec coverage:** The plan implements the requested model: `ContextPart` as the single discriminated union, `ContextPartChunk` as the worker generator type, and `ContextPartChunkLog` as the core-enriched persistence/API shape. + +**Placeholder scan:** No steps rely on `TBD`, unspecified tests, or unnamed files. Commands and expected outcomes are included for each task. + +**Type consistency:** The plan consistently uses `content` for text-bearing parts, `part_kind` for the part discriminator, `token_ids`/`logprobs` for worker-provided token metadata, and `sequence`/`worker_binding_key`/`turn_id` for core-enriched log metadata. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-28-context-part-chunk-stream.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** - execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? diff --git a/docs/superpowers/plans/2026-04-28-evaluation-resource-context-and-scoring.md b/docs/superpowers/plans/2026-04-28-evaluation-resource-context-and-scoring.md new file mode 100644 index 00000000..f714978d --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-evaluation-resource-context-and-scoring.md @@ -0,0 +1,909 @@ +# Evaluation Resource Context and Scoring Patch Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make evaluator criteria fetch their own task-scoped resources, judge final artifacts rather than assistant summaries, and preserve evaluator-normalized scores without double-normalizing. + +**Architecture:** Core remains benchmark-agnostic: it exposes task-scoped resource access through `CriterionRuntime`. Benchmark criteria in `ergon_builtins` decide which resources to read, how to sort final outputs vs scratch files, and what to show verifiers or LLM judges. Evaluation persistence assumes all evaluators return normalized scalar task scores. + +**Tech Stack:** Python, Pydantic models, SQLModel, Ergon `CriterionRuntime`, ResearchRubrics LLM judge, real-LLM rollout artifacts. + +--- + +## Code Change Map + +- Modify: `ergon_core/ergon_core/api/criterion_runtime.py` + - Add optional `task_execution_id` to `list_resources`. + - Add `read_resource_by_id` so criteria can read exact SQL rows after listing. + +- Modify: `ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py` + - Implement optional task-execution scoping for `list_resources`. + - Implement `read_resource_by_id`. + - Keep core generic: no final-vs-scratch classification here. + +- Modify: `ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py` + - Fetch resources from `context.runtime`. + - Classify ResearchRubrics final outputs vs scratch files locally. + - Build the judge prompt from resource content plus final assistant message. + - Record `evaluated_resource_ids` and `evaluation_input`. + +- Modify: `ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py` + - Stop re-normalizing `TaskEvaluationResult.score`. + - Store `summary.normalized_score = result.score`. + +- Modify: `ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py` + - Keep existing ResearchRubrics formula, but clarify metadata with normalized score semantics. + +- Modify: `tests/real_llm/artifact_health.py` + - Detect missing final output via task-scoped resource rows and final-output provenance, not durable blob `file_path`. + +- Tests: + - `tests/unit/state/test_criterion_runtime_di.py` + - `tests/unit/state/test_research_rubrics_benchmark.py` + - `tests/unit/runtime/test_evaluation_summary_contracts.py` + - `tests/unit/runtime/test_real_llm_rollout_artifact_health.py` + +--- + +## Task 1: Extend Core Runtime Resource Access + +**Files:** +- Modify: `ergon_core/ergon_core/api/criterion_runtime.py` +- Modify: `ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py` +- Test: `tests/unit/state/test_criterion_runtime_di.py` + +### Rationale + +Criteria should own context selection. Core should only provide generic resource primitives: + +- list resources for the evaluated task execution by default; +- optionally list resources for an explicit task execution id; +- read exact resources by id to avoid name collisions. + +Core must not know about ResearchRubrics final reports, scratchpads, or judge prompt layout. + +### Patch: Public Protocol + +In `ergon_core/ergon_core/api/criterion_runtime.py`, add `UUID` under `TYPE_CHECKING` or as a normal import. Since Protocol signatures need the type at runtime under postponed annotations are not enabled in this file, use a normal import: + +```python +from uuid import UUID +``` + +Change the resource methods: + +```python +# ── resource I/O ────────────────────────────────────────────────── +async def read_resource(self, name: str) -> bytes: ... +async def read_resource_by_id(self, resource_id: UUID) -> bytes: ... +async def list_resources( + self, + task_execution_id: UUID | None = None, +) -> "list[RunResourceView]": ... +async def get_all_files_for_task(self) -> "dict[str, bytes]": + """Return ``{name: bytes}`` for every resource produced by this task. + + Scoped to the runtime's evaluator-bound task execution. On duplicate + ``name`` s, the newest ``created_at`` wins. Not size-capped. + """ + ... +``` + +### Patch: Concrete Runtime + +In `ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py`, keep the existing SQLModel imports: + +```python +from sqlmodel import Session, desc, select +``` + +Add exact-id reading after `read_resource`: + +```python +async def read_resource_by_id(self, resource_id: UUID) -> bytes: + """Read one worker-published blob by its RunResource primary key.""" + with get_session() as session: + row = session.get(RunResource, resource_id) + + if row is None or row.run_id != self._run_id: + raise ResourceNotFoundError( + f"No run_resource {resource_id!s} for run {self._run_id}" + ) + + result = Path(row.file_path).read_bytes() + logger.info( + "criterion read_resource_by_id run_id=%s resource_id=%s size_bytes=%d", + self._run_id, + resource_id, + len(result), + ) + return result +``` + +Replace `list_resources` with task-aware behavior: + +```python +async def list_resources( + self, + task_execution_id: UUID | None = None, +) -> list[RunResourceView]: + """Return resource DTOs for this run, newest first. + + Defaults to this runtime's evaluated task execution. Passing + ``task_execution_id`` lets a benchmark criterion inspect a related task + explicitly without core knowing benchmark semantics. + """ + effective_execution_id = ( + task_execution_id if task_execution_id is not None else self._task_id + ) + with get_session() as session: + stmt = select(RunResource).where(RunResource.run_id == self._run_id) + if effective_execution_id is not None: + stmt = stmt.where(RunResource.task_execution_id == effective_execution_id) + stmt = stmt.order_by(desc(RunResource.created_at)) + rows = list(session.exec(stmt).all()) + return [RunResourceView.from_row(r) for r in rows] +``` + +### Tests + +In `tests/unit/state/test_criterion_runtime_di.py`, update the protocol test expected method set: + +```python +expected = { + "ensure_sandbox", + "upload_files", + "write_file", + "run_command", + "execute_code", + "cleanup", + "read_resource", + "read_resource_by_id", + "list_resources", + "get_all_files_for_task", + "db_read_session", + "event_sink", +} +``` + +Add tests: + +```python +@pytest.mark.asyncio +async def test_list_resources_defaults_to_runtime_task_execution() -> None: + task_execution_id = uuid4() + runtime = _make_runtime(task_id=task_execution_id) + + mock_row = MagicMock() + mock_session = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=False) + mock_session.exec.return_value.all.return_value = [mock_row] + + with ( + patch( + "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + return_value=mock_session, + ), + patch.object(RunResourceView, "from_row", return_value=MagicMock()) as mock_from_row, + ): + result = await runtime.list_resources() + + assert len(result) == 1 + mock_from_row.assert_called_once_with(mock_row) + # Keep this assertion broad: SQLModel statements are hard to compare, but + # this ensures a DB query was issued through the runtime path. + mock_session.exec.assert_called_once() +``` + +```python +@pytest.mark.asyncio +async def test_read_resource_by_id_reads_exact_blob(tmp_path: Path) -> None: + blob = tmp_path / "abc" + blob.write_bytes(b"exact-resource") + + run_id = uuid4() + resource_id = uuid4() + row = MagicMock() + row.id = resource_id + row.run_id = run_id + row.file_path = str(blob) + + runtime = _make_runtime(run_id=run_id) + + mock_session = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock(return_value=False) + mock_session.get.return_value = row + + with patch( + "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + return_value=mock_session, + ): + result = await runtime.read_resource_by_id(resource_id) + + assert result == b"exact-resource" +``` + +Run: + +```bash +uv run pytest tests/unit/state/test_criterion_runtime_di.py -q +``` + +Expected: all tests pass. + +--- + +## Task 2: Make ResearchRubrics Criterion Fetch and Package Its Own Evidence + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py` +- Test: `tests/unit/state/test_research_rubrics_benchmark.py` + +### Rationale + +ResearchRubrics should judge the actual task artifacts, not the final assistant summary. The built-in criterion should use the generic runtime to fetch resources, then apply ResearchRubrics-specific evidence policy: + +- final outputs first; +- scratch/intermediate resources second; +- final assistant message as status/context only. + +### Patch + +Add imports: + +```python +from uuid import UUID + +from ergon_core.api.run_resource import RunResourceView +``` + +Add constants and a small local evidence type: + +```python +_MAX_RESOURCE_CHARS = 30_000 +_FINAL_OUTPUT_PREFIX = "/workspace/final_output/" + + +class _ResourceEvidence(BaseModel): + model_config = {"frozen": True, "arbitrary_types_allowed": True} + + resource: RunResourceView + content: str + + @property + def resource_id(self) -> str: + return str(self.resource.id) +``` + +Change `evaluate`: + +```python +async def evaluate(self, context: EvaluationContext) -> CriterionResult: + final_outputs, scratch_outputs = await _load_researchrubrics_evidence(context) + user_prompt = _build_user_prompt( + context, + final_outputs=final_outputs, + scratch_outputs=scratch_outputs, + ) + verdict = await call_structured_judge( + messages=[ + JudgeMessage(role="system", content=self.system_prompt), + JudgeMessage(role="user", content=user_prompt), + ], + response_type=ResearchRubricsVerdict, + model=self.model, + ) + evaluated_resource_ids = [ + evidence.resource_id for evidence in [*final_outputs, *scratch_outputs] + ] + return CriterionResult( + name=self.name, + score=self.max_score if verdict.passed else 0.0, + passed=verdict.passed, + weight=self.weight, + feedback=verdict.reasoning, + evaluation_input=_summarize_evaluation_input( + final_outputs=final_outputs, + scratch_outputs=scratch_outputs, + final_assistant_message=context.worker_result.output, + ), + evaluated_resource_ids=evaluated_resource_ids, + metadata={ + "primary_evidence_resource_ids": [e.resource_id for e in final_outputs], + "scratch_evidence_resource_ids": [e.resource_id for e in scratch_outputs], + }, + ) +``` + +Add evidence loading helpers: + +```python +async def _load_researchrubrics_evidence( + context: EvaluationContext, +) -> tuple[list[_ResourceEvidence], list[_ResourceEvidence]]: + if context.runtime is None: + return [], [] + + resources = await context.runtime.list_resources() + final_resources = [resource for resource in resources if _is_final_output_resource(resource)] + scratch_resources = [resource for resource in resources if resource not in final_resources] + + final_outputs = await _read_text_resources(context, final_resources) + scratch_outputs = await _read_text_resources(context, scratch_resources) + return final_outputs, scratch_outputs +``` + +```python +async def _read_text_resources( + context: EvaluationContext, + resources: list[RunResourceView], +) -> list[_ResourceEvidence]: + if context.runtime is None: + return [] + + evidence: list[_ResourceEvidence] = [] + for resource in resources: + if not _is_text_like(resource): + continue + content_bytes = await context.runtime.read_resource_by_id(resource.id) + content = content_bytes.decode("utf-8", errors="replace") + if len(content) > _MAX_RESOURCE_CHARS: + content = content[:_MAX_RESOURCE_CHARS] + "\n\n[truncated]" + evidence.append(_ResourceEvidence(resource=resource, content=content)) + return evidence +``` + +```python +def _is_text_like(resource: RunResourceView) -> bool: + return ( + resource.mime_type.startswith("text/") + or resource.mime_type in {"application/json", "application/x-ndjson"} + or resource.name.endswith((".md", ".txt", ".json", ".jsonl", ".csv")) + ) +``` + +```python +def _is_final_output_resource(resource: RunResourceView) -> bool: + origin = resource.metadata.get("sandbox_origin") + return isinstance(origin, str) and origin.startswith(_FINAL_OUTPUT_PREFIX) +``` + +Replace `_build_user_prompt`: + +```python +def _build_user_prompt( + context: EvaluationContext, + *, + final_outputs: list[_ResourceEvidence], + scratch_outputs: list[_ResourceEvidence], +) -> str: + return "\n\n".join( + [ + f"Original research request:\n{context.task.description}", + _format_resource_section( + "Final output resources (primary answer to judge)", + final_outputs, + empty="No final output resources were published.", + ), + _format_resource_section( + "Scratch/intermediate resources (supporting context; do not treat as final answer)", + scratch_outputs, + empty="No scratch resources were published.", + ), + ( + "Final assistant message (execution summary/status, not the primary answer):\n" + f"{context.worker_result.output}" + ), + ] + ) +``` + +Add format helpers: + +```python +def _format_resource_section( + title: str, + resources: list[_ResourceEvidence], + *, + empty: str, +) -> str: + if not resources: + return f"{title}:\n{empty}" + blocks = [f"{title}:"] + for evidence in resources: + resource = evidence.resource + origin = resource.metadata.get("sandbox_origin") + blocks.append( + "\n".join( + [ + f"--- resource_id={resource.id} name={resource.name} kind={resource.kind}", + f"mime_type={resource.mime_type} sandbox_origin={origin}", + evidence.content, + ] + ) + ) + return "\n\n".join(blocks) +``` + +```python +def _summarize_evaluation_input( + *, + final_outputs: list[_ResourceEvidence], + scratch_outputs: list[_ResourceEvidence], + final_assistant_message: str, +) -> str: + return "\n".join( + [ + "Evidence used by ResearchRubrics judge:", + "final_outputs=" + + ", ".join(f"{e.resource.name}:{e.resource.id}" for e in final_outputs), + "scratch_outputs=" + + ", ".join(f"{e.resource.name}:{e.resource.id}" for e in scratch_outputs), + "final_assistant_message=" + + final_assistant_message[:1000], + ] + ) +``` + +### Tests + +In `tests/unit/state/test_research_rubrics_benchmark.py`, add a fake runtime and direct unit test for the criterion. + +```python +class _Runtime: + def __init__(self, resources, blobs): + self._resources = resources + self._blobs = blobs + + async def list_resources(self, task_execution_id=None): + return self._resources + + async def read_resource_by_id(self, resource_id): + return self._blobs[resource_id] +``` + +Patch `call_structured_judge` and assert: + +```python +@pytest.mark.asyncio +async def test_researchrubrics_judge_uses_final_resource_content(monkeypatch): + from uuid import uuid4 + from ergon_core.api.evaluation_context import EvaluationContext + from ergon_core.api.results import WorkerOutput + from ergon_core.api.run_resource import RunResourceKind, RunResourceView + from ergon_builtins.benchmarks.researchrubrics.judge_criterion import ( + ResearchRubricsJudgeCriterion, + ResearchRubricsVerdict, + ) + + report_id = uuid4() + scratch_id = uuid4() + run_id = uuid4() + execution_id = uuid4() + report = RunResourceView( + id=report_id, + run_id=run_id, + task_execution_id=execution_id, + kind=RunResourceKind.REPORT, + name="report.md", + mime_type="text/markdown", + file_path="/tmp/blob/report", + size_bytes=12, + content_hash="abc", + error=None, + metadata={"sandbox_origin": "/workspace/final_output/report.md"}, + ) + scratch = RunResourceView( + id=scratch_id, + run_id=run_id, + task_execution_id=execution_id, + kind=RunResourceKind.NOTE, + name="notes.md", + mime_type="text/markdown", + file_path="/tmp/blob/notes", + size_bytes=5, + content_hash="def", + error=None, + metadata={"sandbox_origin": "/workspace/scratch/notes.md"}, + ) + captured = {} + + async def fake_judge(*, messages, response_type, model): + captured["prompt"] = messages[1].content + return ResearchRubricsVerdict(reasoning="report satisfies criterion", passed=True) + + monkeypatch.setattr( + "ergon_builtins.benchmarks.researchrubrics.judge_criterion.call_structured_judge", + fake_judge, + ) + + criterion = ResearchRubricsJudgeCriterion( + name="criterion_0", + rubric=RubricCriterion(criterion="Includes sources.", axis="Explicit", weight=2.0), + ) + task = BenchmarkTask( + task_slug="sample", + instance_key="default", + description="Write a report.", + ) + context = EvaluationContext( + run_id=run_id, + task_id=uuid4(), + execution_id=execution_id, + task=task, + worker_result=WorkerOutput(output="Wrote report.md"), + runtime=_Runtime( + [report, scratch], + { + report_id: b"# Findings\nFinal report text", + scratch_id: b"draft notes", + }, + ), + ) + + result = await criterion.evaluate(context) + + assert result.passed is True + assert str(report_id) in result.evaluated_resource_ids + assert str(scratch_id) in result.evaluated_resource_ids + assert "Final output resources" in captured["prompt"] + assert "Final report text" in captured["prompt"] + assert "Scratch/intermediate resources" in captured["prompt"] + assert "draft notes" in captured["prompt"] +``` + +Run: + +```bash +uv run pytest tests/unit/state/test_research_rubrics_benchmark.py -q +``` + +Expected: all tests pass. + +--- + +## Task 3: Align Rollout Artifact Health With Task-Scoped Final Outputs + +**Files:** +- Modify: `tests/real_llm/artifact_health.py` +- Test: `tests/unit/runtime/test_real_llm_rollout_artifact_health.py` + +### Rationale + +Health analysis works on dumped JSONL, not live SQL. It should mirror the same policy: + +- group resources by `task_execution_id`; +- a completed task has a final output if at least one resource has `metadata_json.sandbox_origin` under `/workspace/final_output/`; +- do not compare durable blob `file_path` to logical sandbox paths. + +### Patch + +In `tests/real_llm/artifact_health.py`, add helpers near `_tool_budget_signals`: + +```python +_FINAL_OUTPUT_PREFIX = "/workspace/final_output/" + + +def _resource_metadata(resource: dict[str, Any]) -> dict[str, Any]: # slopcop: ignore[no-typing-any] + metadata = resource.get("metadata_json") or resource.get("metadata") or {} + if isinstance(metadata, str): + return json.loads(metadata) + return metadata if isinstance(metadata, dict) else {} + + +def _is_final_output_resource(resource: dict[str, Any]) -> bool: # slopcop: ignore[no-typing-any] + origin = _resource_metadata(resource).get("sandbox_origin") + return isinstance(origin, str) and origin.startswith(_FINAL_OUTPUT_PREFIX) +``` + +Replace current `missing_final_report` calculation: + +```python +completed_execution_ids = { + str(execution.get("id")) + for execution in executions + if execution.get("status") == "completed" and execution.get("id") is not None +} +final_output_execution_ids = { + str(resource.get("task_execution_id")) + for resource in resources + if resource.get("task_execution_id") is not None and _is_final_output_resource(resource) +} +missing_final_report = bool(completed_execution_ids - final_output_execution_ids) +``` + +This field name can stay `missing_final_report` for now to avoid dashboard churn, but the semantics become “completed task is missing a final-output resource.” + +### Tests + +In `tests/unit/runtime/test_real_llm_rollout_artifact_health.py`, update `_write_minimal_rollout` to optionally write final-output metadata: + +```python +def _write_minimal_rollout( + root: Path, + *, + task_count: int = 1, + evaluation_rows: list[dict] | None = None, + resource_rows: list[dict] | None = None, +) -> None: + ... + execution_ids = [str(uuid4()) for _ in range(task_count)] + ... + _write_jsonl( + db / "run_task_executions.jsonl", + [ + { + "id": execution_ids[idx], + "task_slug": f"task-{idx}", + "status": "completed", + } + for idx in range(task_count) + ], + ) + ... + _write_jsonl( + db / "run_resources.jsonl", + resource_rows + if resource_rows is not None + else [ + { + "id": str(uuid4()), + "task_execution_id": execution_ids[0], + "name": "report.md", + "metadata_json": {"sandbox_origin": "/workspace/final_output/report.md"}, + } + ], + ) +``` + +Add: + +```python +def test_artifact_health_detects_final_output_by_task_resource_metadata(tmp_path: Path) -> None: + execution_id = str(uuid4()) + _write_minimal_rollout( + tmp_path, + task_count=1, + evaluation_rows=[ + { + "id": str(uuid4()), + "score": 0.75, + "summary_json": { + "evaluator_name": "research-rubric", + "normalized_score": 0.75, + "criterion_results": [ + { + "criterion_name": "criterion_0", + "criterion_type": "researchrubrics-llm-judge", + "score": 1.0, + "max_score": 1.0, + "passed": True, + "weight": 1.0, + "status": "passed", + "criterion_description": "Includes citations.", + "feedback": "The report cited source material.", + } + ], + }, + } + ], + resource_rows=[ + { + "id": str(uuid4()), + "task_execution_id": execution_id, + "name": "report.md", + "file_path": "/tmp/ergon-blob/abc", + "metadata_json": {"sandbox_origin": "/workspace/final_output/report.md"}, + } + ], + ) +``` + +If `_write_minimal_rollout` generates execution ids internally, return them from the helper or pass explicit ids. Keep the test focused: final-output detection must use `metadata_json.sandbox_origin`, not durable `file_path`. + +Run: + +```bash +uv run pytest tests/unit/runtime/test_real_llm_rollout_artifact_health.py tests/real_llm/test_artifact_health.py -q +``` + +Expected: all tests pass. + +--- + +## Task 4: Preserve Evaluator-Normalized Scores + +**Files:** +- Modify: `ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py` +- Modify: `ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py` +- Test: `tests/unit/runtime/test_evaluation_summary_contracts.py` +- Test: `tests/unit/state/test_research_rubrics_benchmark.py` + +### Rationale + +New standard: all evaluators return normalized scalar scores in `TaskEvaluationResult.score`. Persistence must record, not reinterpret, that score. + +Current bug: + +```python +total_score = result.score +normalized = total_score / max_score_total if max_score_total > 0 else 0.0 +``` + +For ResearchRubrics, `result.score` is already normalized, so this divides twice. + +### Patch: Persistence + +In `build_evaluation_summary`, replace: + +```python +total_score = result.score +normalized = total_score / max_score_total if max_score_total > 0 else 0.0 +``` + +with: + +```python +normalized = result.score +``` + +Keep `max_score_total` as rubric display metadata: + +```python +return EvaluationSummary( + evaluator_name=result.evaluator_name, + max_score=max_score_total, + normalized_score=normalized, + stages_evaluated=len(stage_names), + stages_passed=stages_passed, + criterion_results=entries, +) +``` + +### Patch: ResearchRubrics Metadata + +In `ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py`, keep the formula and add explicit score metadata: + +```python +return TaskEvaluationResult( + task_slug=task.task_slug, + score=normalized_score, + passed=total_score > 0, + evaluator_name=self.name, + criterion_results=results, + metadata={ + "score_scale": "normalized_0_1", + "raw_score": total_score, + "max_possible": max_possible, + "min_possible": min_possible, + }, +) +``` + +### Tests + +In `tests/unit/runtime/test_evaluation_summary_contracts.py`, add: + +```python +def test_build_evaluation_summary_preserves_evaluator_normalized_score() -> None: + summary = build_evaluation_summary( + _service_result( + feedback="criterion ran", + criterion_score=0.5, + criterion_weight=2.0, + passed=True, + ), + evaluation_input=None, + ) + + assert summary.normalized_score == 0.5 + assert summary.max_score == 1.0 +``` + +To make this test prove the no-double-normalization contract, change the helper's `CriterionSpec` for this test case from `max_score=1.0` to `max_score=2.0`. With the old implementation, `summary.normalized_score` would be `0.25`; with the new contract, it remains `0.5`. + +In `tests/unit/state/test_research_rubrics_benchmark.py`, update expected metadata: + +```python +assert result.metadata == { + "score_scale": "normalized_0_1", + "raw_score": 2.0, + "max_possible": 2.0, + "min_possible": -1.0, +} +``` + +Run: + +```bash +uv run pytest tests/unit/runtime/test_evaluation_summary_contracts.py tests/unit/state/test_research_rubrics_benchmark.py -q +``` + +Expected: all tests pass. + +--- + +## Task 5: Verify With One Real Rollout + +**Files:** +- No new code files. + +### Commands + +Run focused checks: + +```bash +uv run pytest \ + tests/unit/state/test_criterion_runtime_di.py \ + tests/unit/state/test_research_rubrics_benchmark.py \ + tests/unit/runtime/test_evaluation_summary_contracts.py \ + tests/unit/runtime/test_real_llm_rollout_artifact_health.py \ + tests/real_llm/test_artifact_health.py \ + -q +``` + +Expected: all tests pass. + +Run lint/compile for touched files: + +```bash +uv run ruff check \ + ergon_core/ergon_core/api/criterion_runtime.py \ + ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py \ + ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py \ + ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py \ + ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py \ + tests/real_llm/artifact_health.py \ + tests/unit/state/test_criterion_runtime_di.py \ + tests/unit/state/test_research_rubrics_benchmark.py \ + tests/unit/runtime/test_evaluation_summary_contracts.py \ + tests/unit/runtime/test_real_llm_rollout_artifact_health.py +``` + +Expected: `All checks passed!` + +Run compile: + +```bash +uv run python -m compileall -q \ + ergon_core/ergon_core/api/criterion_runtime.py \ + ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py \ + ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py \ + ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py \ + ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py \ + tests/real_llm/artifact_health.py +``` + +Expected: exit code `0`. + +After rebuild, rerun one real sample: + +```bash +ERGON_REAL_LLM=1 \ +ERGON_REAL_LLM_MODEL=openrouter:anthropic/claude-opus-4.7 \ +ERGON_REAL_LLM_WORKER=researchrubrics-workflow-cli-react \ +ERGON_REAL_LLM_LIMIT=1 \ +ERGON_REAL_LLM_BUDGET_USD=25 \ +TEST_HARNESS_SECRET=real-llm-secret \ +uv run pytest tests/real_llm/benchmarks/test_researchrubrics.py --assume-stack-up -vv -s +``` + +Expected rollout properties: + +- terminal status is `completed`; +- artifact health reports `missing_final_report: False`; +- `normalized scores` matches `RunTaskEvaluation.score`; +- criterion `evaluated_resource_ids` contains the report resource id; +- judge feedback references details from the full final report, not just the final assistant summary. + +--- + +## Non-Goals + +- Do not put final-vs-scratch classification in `ergon_core`. +- Do not include full agent conversation in ResearchRubrics judge prompts by default. +- Do not introduce a new persisted table for evidence bundles. +- Do not preserve compatibility with double-normalized summary scores; new runs should use the normalized score invariant. + diff --git a/ergon_builtins/AGENTS.md b/ergon_builtins/AGENTS.md index cfb169a3..ff1678e9 100644 --- a/ergon_builtins/AGENTS.md +++ b/ergon_builtins/AGENTS.md @@ -100,12 +100,10 @@ EVALUATION is populated by whichever **evaluator** you pass with |---|---|---| | `gdpeval` | `benchmarks/gdpeval/sandbox.py` | GDPEval harness sandbox. | | `minif2f` | `benchmarks/minif2f/sandbox_manager.py` | Lean 4 sandbox with the compiler pre-installed. | +| `researchrubrics` | `benchmarks/researchrubrics/sandbox_manager.py` | ResearchRubrics E2B sandbox with Exa tooling. | +| `researchrubrics-vanilla` | `benchmarks/researchrubrics/sandbox_manager.py` | Same sandbox setup for the vanilla benchmark variant. | | `swebench-verified` | `benchmarks/swebench_verified/sandbox_manager.py` | SWE-Bench instance sandbox; installs repo+deps in `_install_dependencies`. | -(`ResearchRubricsSandboxManager` lives in `ergon_core/core/providers/sandbox/` -and is instantiated directly by `researchrubrics-researcher`; it is not in -`SANDBOX_MANAGERS` because nothing else uses it.) - --- ## Model backends (`MODEL_BACKENDS` in registry_core.py) From 14ee2b8b7f896f794522b076bc52ddae1e241ef5 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:45:17 +0100 Subject: [PATCH 36/66] Refactor runtime debugging infrastructure Move sandbox, tracing, and Inngest helpers into clearer runtime packages while preserving real-LLM debugging and smoke-test coverage. Made-with: Cursor --- .../benchmarks/gdpeval/sandbox.py | 2 +- .../benchmarks/gdpeval/sandbox_utils.py | 2 +- .../benchmarks/gdpeval/toolkit.py | 2 +- .../benchmarks/minif2f/sandbox_manager.py | 2 +- .../researchrubrics/sandbox_manager.py | 14 +- .../swebench_verified/sandbox_manager.py | 4 +- ergon_builtins/ergon_builtins/registry.py | 2 +- .../ergon_builtins/registry_core.py | 2 +- .../ergon_builtins/registry_data.py | 8 +- .../workers/baselines/tool_budget.py | 61 ++ .../research_rubrics/researcher_worker.py | 2 +- .../workflow_cli_react_worker.py | 2 +- ergon_cli/ergon_cli/commands/benchmark.py | 2 +- ergon_core/ergon_core/core/api/app.py | 8 +- .../ergon_core/core/api/test_harness.py | 2 +- .../ergon_core/core/dashboard/emitter.py | 2 +- .../ergon_core/core/providers/__init__.py | 0 .../core/providers/generation/__init__.py | 4 - .../core/runtime/errors/error_payload.py | 35 -- .../runtime/evaluation/criterion_runtime.py | 6 +- .../runtime/evaluation/inngest_executor.py | 2 +- .../core/runtime/evaluation/protocols.py | 2 +- .../runtime/inngest/benchmark_run_start.py | 2 +- .../runtime/inngest/cancel_orphan_subtasks.py | 2 +- .../core/runtime/inngest/check_evaluators.py | 4 +- .../runtime/inngest/cleanup_cancelled_task.py | 2 +- .../{inngest_client.py => inngest/client.py} | 0 .../core/runtime/inngest/complete_workflow.py | 2 +- .../core/runtime/inngest/evaluate_task_run.py | 4 +- .../core/runtime/inngest/execute_task.py | 30 +- .../core/runtime/inngest/fail_workflow.py | 2 +- .../core/runtime/inngest/persist_outputs.py | 6 +- .../runtime/inngest/propagate_execution.py | 4 +- .../registry.py} | 0 .../core/runtime/inngest/run_cleanup.py | 4 +- .../core/runtime/inngest/sandbox_setup.py | 4 +- .../core/runtime/inngest/start_workflow.py | 2 +- .../core/runtime/inngest/worker_execute.py | 41 +- .../core/runtime/services/run_service.py | 2 +- .../services/task_management_service.py | 2 +- .../core/runtime/services/workflow_service.py | 2 +- ergon_core/ergon_core/core/runtime/tracing.py | 572 ------------------ .../core/runtime/tracing/__init__.py | 85 +++ .../core/runtime/tracing/attributes.py | 46 ++ .../core/runtime/tracing/contexts.py | 177 ++++++ .../ergon_core/core/runtime/tracing/ids.py | 56 ++ .../ergon_core/core/runtime/tracing/noop.py | 47 ++ .../ergon_core/core/runtime/tracing/otel.py | 135 +++++ .../ergon_core/core/runtime/tracing/sinks.py | 27 + .../ergon_core/core/runtime/tracing/types.py | 67 ++ .../core/{providers => }/sandbox/__init__.py | 2 +- .../core/{providers => }/sandbox/errors.py | 0 .../{providers => }/sandbox/event_sink.py | 0 .../sandbox/instrumentation.py | 4 +- .../core/{providers => }/sandbox/lifecycle.py | 2 +- .../core/{providers => }/sandbox/manager.py | 6 +- .../sandbox/resource_publisher.py | 0 .../core/{providers => }/sandbox/utils.py | 0 .../test_support/sandbox/stub_manager.py | 2 +- .../test_support/smoke_fixtures/sandbox.py | 2 +- .../smoke_fixtures/smoke_base/leaf_base.py | 2 +- .../smoke_fixtures/smoke_base/subworker.py | 2 +- pyproject.toml | 2 +- scripts/spike_openrouter_reasoning.py | 141 +++++ tests/integration/conftest.py | 2 +- .../minif2f/test_sandbox_manager.py | 14 +- .../minif2f/test_verification_integration.py | 2 +- .../researchrubrics/test_sandbox_manager.py | 16 +- .../sandbox/test_required_env_keys.py | 14 +- .../swebench_verified/test_sandbox_manager.py | 10 +- .../test_model_field_descriptions.py | 82 +++ .../test_swebench_sandbox_manager.py | 4 +- .../unit/builtins/test_logfire_pydantic_ai.py | 53 ++ tests/unit/builtins/test_tool_budget.py | 51 ++ .../test_criterion_runtime_reconnect.py | 2 +- .../test_failed_task_sandbox_cleanup.py | 2 +- tests/unit/runtime/test_failure_error_json.py | 41 -- .../test_inngest_criterion_executor.py | 86 +++ .../runtime/test_inngest_package_layout.py | 10 + .../test_worker_execute_output_failure.py | 12 - .../test_ensure_sandbox_idempotence.py | 6 +- .../sandbox/test_sandbox_lifecycle_service.py | 4 +- tests/unit/sandbox/test_sandbox_reconnect.py | 28 +- .../test_leaf_sends_completion_message.py | 2 +- .../smoke_base/test_smoke_sandbox_manager.py | 6 +- tests/unit/state/test_criterion_runtime_di.py | 2 +- tests/unit/test_dashboard_emitter_wiring.py | 2 +- 87 files changed, 1286 insertions(+), 823 deletions(-) rename ergon_core/ergon_core/core/providers/sandbox/research_rubrics_manager.py => ergon_builtins/ergon_builtins/benchmarks/researchrubrics/sandbox_manager.py (91%) create mode 100644 ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py delete mode 100644 ergon_core/ergon_core/core/providers/__init__.py delete mode 100644 ergon_core/ergon_core/core/providers/generation/__init__.py delete mode 100644 ergon_core/ergon_core/core/runtime/errors/error_payload.py rename ergon_core/ergon_core/core/runtime/{inngest_client.py => inngest/client.py} (100%) rename ergon_core/ergon_core/core/runtime/{inngest_registry.py => inngest/registry.py} (100%) delete mode 100644 ergon_core/ergon_core/core/runtime/tracing.py create mode 100644 ergon_core/ergon_core/core/runtime/tracing/__init__.py create mode 100644 ergon_core/ergon_core/core/runtime/tracing/attributes.py create mode 100644 ergon_core/ergon_core/core/runtime/tracing/contexts.py create mode 100644 ergon_core/ergon_core/core/runtime/tracing/ids.py create mode 100644 ergon_core/ergon_core/core/runtime/tracing/noop.py create mode 100644 ergon_core/ergon_core/core/runtime/tracing/otel.py create mode 100644 ergon_core/ergon_core/core/runtime/tracing/sinks.py create mode 100644 ergon_core/ergon_core/core/runtime/tracing/types.py rename ergon_core/ergon_core/core/{providers => }/sandbox/__init__.py (72%) rename ergon_core/ergon_core/core/{providers => }/sandbox/errors.py (100%) rename ergon_core/ergon_core/core/{providers => }/sandbox/event_sink.py (100%) rename ergon_core/ergon_core/core/{providers => }/sandbox/instrumentation.py (98%) rename ergon_core/ergon_core/core/{providers => }/sandbox/lifecycle.py (96%) rename ergon_core/ergon_core/core/{providers => }/sandbox/manager.py (99%) rename ergon_core/ergon_core/core/{providers => }/sandbox/resource_publisher.py (100%) rename ergon_core/ergon_core/core/{providers => }/sandbox/utils.py (100%) create mode 100644 scripts/spike_openrouter_reasoning.py create mode 100644 tests/unit/architecture/test_model_field_descriptions.py create mode 100644 tests/unit/builtins/test_logfire_pydantic_ai.py create mode 100644 tests/unit/builtins/test_tool_budget.py create mode 100644 tests/unit/runtime/test_inngest_criterion_executor.py create mode 100644 tests/unit/runtime/test_inngest_package_layout.py delete mode 100644 tests/unit/runtime/test_worker_execute_output_failure.py diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox.py index 3dfb1c39..fbcd0bdf 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox.py @@ -7,7 +7,7 @@ import logging from uuid import UUID -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager try: from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox_utils.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox_utils.py index f3cae0bd..e7b20fd4 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox_utils.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox_utils.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, Field if TYPE_CHECKING: - from ergon_core.core.providers.sandbox.manager import BaseSandboxManager + from ergon_core.core.sandbox.manager import BaseSandboxManager logger = logging.getLogger(__name__) diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/toolkit.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/toolkit.py index dcae6c1b..a7346f99 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/toolkit.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/toolkit.py @@ -25,7 +25,7 @@ ) if TYPE_CHECKING: - from ergon_core.core.providers.sandbox.manager import BaseSandboxManager + from ergon_core.core.sandbox.manager import BaseSandboxManager class QAExchange: diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/sandbox_manager.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/sandbox_manager.py index 7a6d42e3..e9f8c650 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/sandbox_manager.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/sandbox_manager.py @@ -10,7 +10,7 @@ class ``_install_dependencies`` hook is sufficient — the verify step just import logging from uuid import UUID -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.minif2f.sandbox.utils import ( REGISTRY_PATH, diff --git a/ergon_core/ergon_core/core/providers/sandbox/research_rubrics_manager.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/sandbox_manager.py similarity index 91% rename from ergon_core/ergon_core/core/providers/sandbox/research_rubrics_manager.py rename to ergon_builtins/ergon_builtins/benchmarks/researchrubrics/sandbox_manager.py index 553ebe11..d512b661 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/research_rubrics_manager.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/sandbox_manager.py @@ -2,7 +2,7 @@ Subclasses ``BaseSandboxManager`` to pre-install research tooling (``exa-py``) and scaffold the workspace directory layout used by the research toolkit's -skill handlers. Provides a ``publisher_for`` factory so toolkit methods can +skill handlers. Provides a ``publisher_for`` factory so toolkit methods can trigger ``SandboxResourcePublisher.sync()`` after write operations. """ @@ -10,8 +10,8 @@ from typing import ClassVar from uuid import UUID -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager -from ergon_core.core.providers.sandbox.resource_publisher import ( +from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.resource_publisher import ( SandboxResourcePublisher, ) @@ -36,9 +36,9 @@ class ResearchRubricsSandboxManager(BaseSandboxManager): """Singleton sandbox manager for researchrubrics benchmarks. - One ``AsyncSandbox`` per root task. ``exa-py`` is installed and the + One ``AsyncSandbox`` per root task. ``exa-py`` is installed and the workspace directory tree is scaffolded at ``create`` time via the - ``_install_dependencies`` override. ``EXA_API_KEY`` from ``settings`` + ``_install_dependencies`` override. ``EXA_API_KEY`` from ``settings`` is injected into the sandbox process env so the in-sandbox Exa skill calls (``exa_search``, ``exa_qa``, ``exa_get_content``) can authenticate. @@ -49,7 +49,7 @@ class ResearchRubricsSandboxManager(BaseSandboxManager): type_slug: ClassVar[str] = "researchrubrics" - # In-sandbox tool keys sourced from ``settings``. The base class's + # In-sandbox tool keys sourced from ``settings``. The base class's # ``_compose_envs`` helper reads ``settings.exa_api_key`` and merges # it into the ``envs`` dict threaded to ``AsyncSandbox.create``. required_env_keys: ClassVar[tuple[str, ...]] = ("EXA_API_KEY",) @@ -67,7 +67,7 @@ async def _install_dependencies( if AsyncSandbox is None: # The class-level ``try: from e2b_code_interpreter ...`` lets us # import this module when e2b isn't installed (documentation builds, - # type-only contexts). Reaching this method with no e2b means + # type-only contexts). Reaching this method with no e2b means # somebody constructed the manager without the optional dep -- # fail fast with a clear message instead of a confusing # ``NoneType is not callable`` deeper down. diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/sandbox_manager.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/sandbox_manager.py index 94764b22..8f4a7ed2 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/sandbox_manager.py +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/sandbox_manager.py @@ -13,8 +13,8 @@ from uuid import UUID from ergon_core.core.persistence.queries import queries -from ergon_core.core.providers.sandbox.errors import SandboxSetupError -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.errors import SandboxSetupError +from ergon_core.core.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.swebench_verified.criterion import make_test_spec from ergon_builtins.benchmarks.swebench_verified.sandbox.utils import resolve_template diff --git a/ergon_builtins/ergon_builtins/registry.py b/ergon_builtins/ergon_builtins/registry.py index 97dc5917..b6453ce2 100644 --- a/ergon_builtins/ergon_builtins/registry.py +++ b/ergon_builtins/ergon_builtins/registry.py @@ -8,7 +8,7 @@ import structlog from ergon_core.api import Benchmark, Evaluator, Worker -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager from ergon_builtins.models.resolution import ( ResolvedModel, diff --git a/ergon_builtins/ergon_builtins/registry_core.py b/ergon_builtins/ergon_builtins/registry_core.py index 3c599194..1068c7e0 100644 --- a/ergon_builtins/ergon_builtins/registry_core.py +++ b/ergon_builtins/ergon_builtins/registry_core.py @@ -10,7 +10,7 @@ from uuid import UUID from ergon_core.api import Benchmark, Evaluator, Worker -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.gdpeval.rubric import StagedRubric from ergon_builtins.benchmarks.gdpeval.sandbox import GDPEvalSandboxManager diff --git a/ergon_builtins/ergon_builtins/registry_data.py b/ergon_builtins/ergon_builtins/registry_data.py index 215eaf69..4f75facf 100644 --- a/ergon_builtins/ergon_builtins/registry_data.py +++ b/ergon_builtins/ergon_builtins/registry_data.py @@ -6,14 +6,14 @@ from collections.abc import Callable from ergon_core.api import Benchmark, Evaluator, Worker -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager -from ergon_core.core.providers.sandbox.research_rubrics_manager import ( - ResearchRubricsSandboxManager, -) +from ergon_core.core.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.gdpeval.benchmark import GDPEvalBenchmark from ergon_builtins.benchmarks.researchrubrics.benchmark import ResearchRubricsBenchmark from ergon_builtins.benchmarks.researchrubrics.rubric import ResearchRubricsRubric +from ergon_builtins.benchmarks.researchrubrics.sandbox_manager import ( + ResearchRubricsSandboxManager, +) from ergon_builtins.benchmarks.researchrubrics.vanilla import ( ResearchRubricsVanillaBenchmark, ) diff --git a/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py b/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py new file mode 100644 index 00000000..2b590ac5 --- /dev/null +++ b/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +ToolBudgetKind = Literal["workflow", "other", "finalization"] +ToolBudgetExhaustedStatus = Literal["TOOL_BUDGET_EXHAUSTED"] + + +class AgentToolBudgetExhaustedResult(BaseModel): + status: ToolBudgetExhaustedStatus = "TOOL_BUDGET_EXHAUSTED" + reason: str + message: str + budget_state: dict[str, Any] # slopcop: ignore[no-typing-any] + + +class AgentToolBudgetState(BaseModel): + max_workflow_tool_calls: int = 12 + max_other_tool_calls: int = 12 + workflow_tool_calls: int = 0 + other_tool_calls: int = 0 + finalization_tool_calls: int = 0 + calls_by_tool: dict[str, int] = Field(default_factory=dict) + + def increment(self, tool_name: str, kind: ToolBudgetKind) -> int: + self.calls_by_tool[tool_name] = self.calls_by_tool.get(tool_name, 0) + 1 + + if kind == "workflow": + self.workflow_tool_calls += 1 + return self.workflow_tool_calls + if kind == "finalization": + self.finalization_tool_calls += 1 + return self.finalization_tool_calls + self.other_tool_calls += 1 + return self.other_tool_calls + + def snapshot(self) -> dict[str, Any]: # slopcop: ignore[no-typing-any] + return { + "workflow_tool_calls": self.workflow_tool_calls, + "max_workflow_tool_calls": self.max_workflow_tool_calls, + "other_tool_calls": self.other_tool_calls, + "max_other_tool_calls": self.max_other_tool_calls, + "finalization_tool_calls": self.finalization_tool_calls, + "calls_by_tool": dict(sorted(self.calls_by_tool.items())), + } + + def exhausted_result(self, reason: str) -> AgentToolBudgetExhaustedResult: + return AgentToolBudgetExhaustedResult( + reason=reason, + message=( + "Stop calling tools in this category. Use the context/resources already " + "available and produce the best possible final output. If the output is " + "incomplete, state what context or resource was missing." + ), + budget_state=self.snapshot(), + ) + + +class AgentToolBudgetDeps(BaseModel): + tool_budget: AgentToolBudgetState diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py index 3c1b981a..e0fd720a 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py @@ -14,7 +14,7 @@ from ergon_core.core.runtime.resources import RunResourceView from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext -from ergon_core.core.providers.sandbox.research_rubrics_manager import ( +from ergon_builtins.benchmarks.researchrubrics.sandbox_manager import ( ResearchRubricsSandboxManager, ) diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py index 7a7a3a05..dfa624d6 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py @@ -7,7 +7,7 @@ from ergon_core.core.runtime.resources import RunResourceView from ergon_core.api.task_types import BenchmarkTask from ergon_core.api.worker_context import WorkerContext -from ergon_core.core.providers.sandbox.research_rubrics_manager import ( +from ergon_builtins.benchmarks.researchrubrics.sandbox_manager import ( ResearchRubricsSandboxManager, ) diff --git a/ergon_cli/ergon_cli/commands/benchmark.py b/ergon_cli/ergon_cli/commands/benchmark.py index 72d692fb..1e0993cf 100644 --- a/ergon_cli/ergon_cli/commands/benchmark.py +++ b/ergon_cli/ergon_cli/commands/benchmark.py @@ -19,7 +19,7 @@ from ergon_core.core.persistence.shared.enums import TERMINAL_RUN_STATUSES from ergon_core.core.persistence.telemetry.models import RunRecord from ergon_core.core.runtime.events.task_events import WorkflowStartedEvent -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service from ergon_core.core.runtime.services.run_service import create_run from ergon_core.core.settings import settings diff --git a/ergon_core/ergon_core/core/api/app.py b/ergon_core/ergon_core/core/api/app.py index ccd1b5cb..9e213661 100644 --- a/ergon_core/ergon_core/core/api/app.py +++ b/ergon_core/ergon_core/core/api/app.py @@ -28,15 +28,15 @@ from ergon_core.core.api.test_harness import router as _test_harness_router from ergon_core.core.dashboard.emitter import dashboard_emitter from ergon_core.core.persistence.shared.db import ensure_db, get_session -from ergon_core.core.providers.sandbox.event_sink import ( +from ergon_core.core.sandbox.event_sink import ( CompoundSandboxEventSink, DashboardEmitterSandboxEventSink, PostgresSandboxEventSink, ) -from ergon_core.core.providers.sandbox.manager import DefaultSandboxManager +from ergon_core.core.sandbox.manager import DefaultSandboxManager from ergon_core.core.rl.rollout_service import RolloutService -from ergon_core.core.runtime.inngest_client import inngest_client -from ergon_core.core.runtime.inngest_registry import ALL_FUNCTIONS +from ergon_core.core.runtime.inngest.client import inngest_client +from ergon_core.core.runtime.inngest.registry import ALL_FUNCTIONS from ergon_core.core.settings import Settings, settings from fastapi import FastAPI diff --git a/ergon_core/ergon_core/core/api/test_harness.py b/ergon_core/ergon_core/core/api/test_harness.py index 117e0836..336b5912 100644 --- a/ergon_core/ergon_core/core/api/test_harness.py +++ b/ergon_core/ergon_core/core/api/test_harness.py @@ -34,7 +34,7 @@ Thread, ) from ergon_core.core.runtime.events.task_events import WorkflowStartedEvent -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service from ergon_core.core.runtime.services.run_service import create_run from fastapi import APIRouter, Depends, Header, HTTPException, status diff --git a/ergon_core/ergon_core/core/dashboard/emitter.py b/ergon_core/ergon_core/core/dashboard/emitter.py index cd91203d..6719ffe8 100644 --- a/ergon_core/ergon_core/core/dashboard/emitter.py +++ b/ergon_core/ergon_core/core/dashboard/emitter.py @@ -25,7 +25,7 @@ ) from ergon_core.core.persistence.queries import queries from ergon_core.core.runtime.events.task_events import TaskCancelledEvent -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.services.cohort_schemas import CohortSummaryDto from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service from ergon_core.core.runtime.services.cohort_stats_service import ( diff --git a/ergon_core/ergon_core/core/providers/__init__.py b/ergon_core/ergon_core/core/providers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ergon_core/ergon_core/core/providers/generation/__init__.py b/ergon_core/ergon_core/core/providers/generation/__init__.py deleted file mode 100644 index 765985ec..00000000 --- a/ergon_core/ergon_core/core/providers/generation/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Generation provider namespace. - -Concrete PydanticAI model resolution lives in ``ergon_builtins.models``. -""" diff --git a/ergon_core/ergon_core/core/runtime/errors/error_payload.py b/ergon_core/ergon_core/core/runtime/errors/error_payload.py deleted file mode 100644 index e75806d3..00000000 --- a/ergon_core/ergon_core/core/runtime/errors/error_payload.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Structured runtime error payloads for persisted execution failures.""" - -import traceback -from collections.abc import Mapping -from typing import Any - -from ergon_core.core.json_types import JsonObject -from pydantic import BaseModel, Field - - -class RuntimeErrorPayload(BaseModel): - """Persisted shape for task execution failures.""" - - message: str - exception_type: str - phase: str - stack: str - context: dict[str, str] = Field(default_factory=dict) - - -def build_error_json( - exc: BaseException, - *, - phase: str, - context: Mapping[str, Any] | None = None, -) -> JsonObject: - """Return stack-rich, queryable error details for PG persistence.""" - payload = RuntimeErrorPayload( - message=str(exc), - exception_type=type(exc).__name__, - phase=phase, - stack="".join(traceback.format_exception(type(exc), exc, exc.__traceback__)), - context={key: str(value) for key, value in (context or {}).items()}, - ) - return payload.model_dump(mode="json") diff --git a/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py b/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py index 545c0d9a..58716002 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py +++ b/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py @@ -13,8 +13,8 @@ ) from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource -from ergon_core.core.providers.sandbox.errors import SandboxExpiredError -from ergon_core.core.providers.sandbox.event_sink import ( +from ergon_core.core.sandbox.errors import SandboxExpiredError +from ergon_core.core.sandbox.event_sink import ( NoopSandboxEventSink, SandboxEventSink, ) @@ -24,7 +24,7 @@ from sqlmodel import Session, desc, select if TYPE_CHECKING: - from ergon_core.core.providers.sandbox.manager import AsyncSandbox, BaseSandboxManager + from ergon_core.core.sandbox.manager import AsyncSandbox, BaseSandboxManager logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py b/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py index 85888538..3810fdd4 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py +++ b/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py @@ -27,7 +27,7 @@ ) if TYPE_CHECKING: - from ergon_core.core.providers.sandbox.manager import BaseSandboxManager + from ergon_core.core.sandbox.manager import BaseSandboxManager class InngestCriterionExecutor: diff --git a/ergon_core/ergon_core/core/runtime/evaluation/protocols.py b/ergon_core/ergon_core/core/runtime/evaluation/protocols.py index 59c74083..79f2695a 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/protocols.py +++ b/ergon_core/ergon_core/core/runtime/evaluation/protocols.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from sqlmodel import Session - from ergon_core.core.providers.sandbox.event_sink import SandboxEventSink + from ergon_core.core.sandbox.event_sink import SandboxEventSink from ergon_core.core.runtime.resources import RunResourceView __all__ = ["CommandResult", "CriterionRuntime", "SandboxResult"] diff --git a/ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py b/ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py index f517de7a..f504beb7 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py +++ b/ergon_core/ergon_core/core/runtime/inngest/benchmark_run_start.py @@ -14,7 +14,7 @@ from ergon_core.core.runtime.errors import RegistryLookupError from ergon_core.core.runtime.events.base import InngestEventContract from ergon_core.core.runtime.events.task_events import WorkflowStartedEvent -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.services.inngest_function_results import ( BenchmarkRunStartResult, ) diff --git a/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py b/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py index 3daf00a8..a79efb7d 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py +++ b/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py @@ -21,7 +21,7 @@ TaskCancelledEvent, TaskFailedEvent, ) -from ergon_core.core.runtime.inngest_client import RUN_CANCEL, inngest_client +from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client from ergon_core.core.runtime.services.subtask_blocking_service import SubtaskBlockingService from ergon_core.core.runtime.services.subtask_cancellation_service import ( SubtaskCancellationService, diff --git a/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py b/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py index cfee52bd..31d00364 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py +++ b/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py @@ -9,12 +9,12 @@ import logging import inngest -from ergon_core.core.providers.sandbox.lifecycle import terminate_sandbox_by_id +from ergon_core.core.sandbox.lifecycle import terminate_sandbox_by_id from ergon_core.core.runtime.events.task_events import ( TaskCompletedEvent, ) +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.inngest.evaluate_task_run import evaluate_task_run -from ergon_core.core.runtime.inngest_client import inngest_client from ergon_core.core.runtime.services.child_function_payloads import ( EvaluateTaskRunRequest, ) diff --git a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py b/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py index 649438c8..793bd81b 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py +++ b/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py @@ -13,7 +13,7 @@ from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.runtime.events.task_events import TaskCancelledEvent -from ergon_core.core.runtime.inngest_client import RUN_CANCEL, inngest_client +from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client from ergon_core.core.runtime.services.task_cleanup_dto import CleanupResult from ergon_core.core.runtime.services.task_cleanup_service import TaskCleanupService diff --git a/ergon_core/ergon_core/core/runtime/inngest_client.py b/ergon_core/ergon_core/core/runtime/inngest/client.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/inngest_client.py rename to ergon_core/ergon_core/core/runtime/inngest/client.py diff --git a/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py b/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py index 20fc5393..0f5a31ef 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py +++ b/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py @@ -10,7 +10,7 @@ from ergon_core.core.persistence.telemetry.models import RunRecord from ergon_core.core.runtime.events.infrastructure_events import RunCleanupEvent from ergon_core.core.runtime.events.task_events import WorkflowCompletedEvent -from ergon_core.core.runtime.inngest_client import RUN_CANCEL, inngest_client +from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client from ergon_core.core.runtime.services.inngest_function_results import WorkflowCompleteResult from ergon_core.core.runtime.services.orchestration_dto import FinalizeWorkflowCommand from ergon_core.core.runtime.services.workflow_finalization_service import ( diff --git a/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py b/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py index 363afed7..aa823f13 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py +++ b/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py @@ -12,11 +12,11 @@ from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload from ergon_core.core.dashboard.emitter import dashboard_emitter from ergon_core.core.persistence.queries import queries -from ergon_core.core.providers.sandbox.manager import DefaultSandboxManager +from ergon_core.core.sandbox.manager import DefaultSandboxManager from ergon_core.core.runtime.errors import ContractViolationError, RegistryLookupError from ergon_core.core.runtime.evaluation.evaluation_schemas import TaskEvaluationContext from ergon_core.core.runtime.evaluation.inngest_executor import InngestCriterionExecutor -from ergon_core.core.runtime.inngest_client import RUN_CANCEL, inngest_client +from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client from ergon_core.core.runtime.services.child_function_payloads import ( EvaluateTaskRunRequest, ) diff --git a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py b/ergon_core/ergon_core/core/runtime/inngest/execute_task.py index a5add7fc..cd63cd3d 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py +++ b/ergon_core/ergon_core/core/runtime/inngest/execute_task.py @@ -5,11 +5,11 @@ """ import logging +import traceback from datetime import UTC, datetime import inngest from ergon_core.core.runtime.errors import ContractViolationError -from ergon_core.core.runtime.errors.error_payload import build_error_json from ergon_core.core.runtime.events.task_events import ( TaskCompletedEvent, TaskFailedEvent, @@ -18,7 +18,7 @@ from ergon_core.core.runtime.inngest.persist_outputs import persist_outputs_fn from ergon_core.core.runtime.inngest.sandbox_setup import sandbox_setup_fn from ergon_core.core.runtime.inngest.worker_execute import worker_execute_fn -from ergon_core.core.runtime.inngest_client import RUN_CANCEL, TASK_CANCEL, inngest_client +from ergon_core.core.runtime.inngest.client import RUN_CANCEL, TASK_CANCEL, inngest_client from ergon_core.core.runtime.services.child_function_payloads import ( PersistOutputsRequest, SandboxSetupRequest, @@ -309,18 +309,22 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: run_id=payload.run_id, task_id=payload.task_id, error_message=error_msg, - error_json=build_error_json( - exc, - phase="task_execute", - context={ - "task_slug": prepared.task_slug, - "assigned_worker_slug": prepared.assigned_worker_slug, - "worker_type": prepared.worker_type, - "model_target": prepared.model_target, - "node_id": prepared.node_id, - "execution_id": prepared.execution_id, + error_json={ + "message": error_msg, + "exception_type": type(exc).__name__, + "phase": "task_execute", + "stack": "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ), + "context": { + "task_slug": str(prepared.task_slug), + "assigned_worker_slug": str(prepared.assigned_worker_slug), + "worker_type": str(prepared.worker_type), + "model_target": str(prepared.model_target), + "node_id": str(prepared.node_id), + "execution_id": str(prepared.execution_id), }, - ), + }, ) ) diff --git a/ergon_core/ergon_core/core/runtime/inngest/fail_workflow.py b/ergon_core/ergon_core/core/runtime/inngest/fail_workflow.py index 4dda0b84..20f3e853 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/fail_workflow.py +++ b/ergon_core/ergon_core/core/runtime/inngest/fail_workflow.py @@ -10,7 +10,7 @@ from ergon_core.core.runtime.errors import DataIntegrityError from ergon_core.core.runtime.events.infrastructure_events import RunCleanupEvent from ergon_core.core.runtime.events.task_events import WorkflowFailedEvent -from ergon_core.core.runtime.inngest_client import RUN_CANCEL, inngest_client +from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client from ergon_core.core.runtime.services.inngest_function_results import WorkflowFailedResult from ergon_core.core.runtime.tracing import ( CompletedSpan, diff --git a/ergon_core/ergon_core/core/runtime/inngest/persist_outputs.py b/ergon_core/ergon_core/core/runtime/inngest/persist_outputs.py index d162fac2..d4747f3e 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/persist_outputs.py +++ b/ergon_core/ergon_core/core/runtime/inngest/persist_outputs.py @@ -13,13 +13,13 @@ import inngest from ergon_builtins.registry import SANDBOX_MANAGERS -from ergon_core.core.providers.sandbox.manager import ( +from ergon_core.core.sandbox.manager import ( BaseSandboxManager, DefaultSandboxManager, ) -from ergon_core.core.providers.sandbox.resource_publisher import SandboxResourcePublisher +from ergon_core.core.sandbox.resource_publisher import SandboxResourcePublisher from ergon_core.core.runtime.errors import ContractViolationError -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.services.child_function_payloads import PersistOutputsRequest from ergon_core.core.runtime.services.inngest_function_results import PersistOutputsResult from ergon_core.core.runtime.tracing import ( diff --git a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py b/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py index 518c001b..2192bbfe 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py +++ b/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py @@ -7,7 +7,7 @@ from datetime import UTC, datetime import inngest -from ergon_core.core.providers.sandbox.lifecycle import terminate_sandbox_by_id +from ergon_core.core.sandbox.lifecycle import terminate_sandbox_by_id from ergon_core.core.runtime.events.task_events import ( TaskCancelledEvent, TaskCompletedEvent, @@ -16,7 +16,7 @@ WorkflowCompletedEvent, WorkflowFailedEvent, ) -from ergon_core.core.runtime.inngest_client import RUN_CANCEL, inngest_client +from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client from ergon_core.core.runtime.services.inngest_function_results import TaskPropagateResult from ergon_core.core.runtime.services.orchestration_dto import ( PropagateTaskCompletionCommand, diff --git a/ergon_core/ergon_core/core/runtime/inngest_registry.py b/ergon_core/ergon_core/core/runtime/inngest/registry.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/inngest_registry.py rename to ergon_core/ergon_core/core/runtime/inngest/registry.py diff --git a/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py b/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py index 88a83fdc..b8d9fbf1 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py +++ b/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py @@ -11,10 +11,10 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import RunStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.providers.sandbox.lifecycle import terminate_sandbox_by_id +from ergon_core.core.sandbox.lifecycle import terminate_sandbox_by_id from ergon_core.core.runtime.errors import ConfigurationError, DataIntegrityError from ergon_core.core.runtime.events.infrastructure_events import RunCleanupEvent -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.services.inngest_function_results import RunCleanupResult logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py b/ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py index 6450bade..a785262a 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py +++ b/ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py @@ -14,9 +14,9 @@ from ergon_builtins.registry import SANDBOX_MANAGERS from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager, DefaultSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager, DefaultSandboxManager from ergon_core.core.runtime.errors import DataIntegrityError -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.services.child_function_payloads import SandboxSetupRequest from ergon_core.core.runtime.services.inngest_function_results import SandboxReadyResult from ergon_core.core.runtime.tracing import ( diff --git a/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py b/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py index da57ea8d..040940ab 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py +++ b/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py @@ -14,7 +14,7 @@ TaskReadyEvent, WorkflowStartedEvent, ) -from ergon_core.core.runtime.inngest_client import RUN_CANCEL, inngest_client +from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client from ergon_core.core.runtime.services.inngest_function_results import WorkflowStartResult from ergon_core.core.runtime.services.orchestration_dto import InitializeWorkflowCommand from ergon_core.core.runtime.services.workflow_initialization_service import ( diff --git a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py index b29540f0..e5947b7c 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py +++ b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py @@ -7,11 +7,11 @@ """ import logging +import traceback from datetime import UTC, datetime import inngest from ergon_builtins.registry import BENCHMARKS, WORKERS -from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload from ergon_core.api.worker_context import WorkerContext from ergon_core.core.dashboard.emitter import dashboard_emitter @@ -20,8 +20,7 @@ from ergon_core.core.persistence.queries import queries from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.runtime.errors import RegistryLookupError -from ergon_core.core.runtime.errors.error_payload import build_error_json -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.services.child_function_payloads import WorkerExecuteRequest from ergon_core.core.runtime.services.inngest_function_results import WorkerExecuteResult from ergon_core.core.runtime.tracing import ( @@ -34,22 +33,6 @@ logger = logging.getLogger(__name__) -def _worker_execute_result_from_output(output: WorkerOutput) -> WorkerExecuteResult: - return WorkerExecuteResult( - success=output.success, - final_assistant_message=output.output, - error=None if output.success else output.output, - ) - - -def _worker_execute_result_from_exception(exc: BaseException) -> WorkerExecuteResult: - return WorkerExecuteResult( - success=False, - error=str(exc), - error_json=build_error_json(exc, phase="worker_execute"), - ) - - @inngest_client.create_function( fn_id="worker-execute", trigger=inngest.TriggerEvent(event="task/worker-execute"), @@ -146,7 +129,19 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: turn_count, error_msg, ) - return _worker_execute_result_from_exception(exc) + return WorkerExecuteResult( + success=False, + error=error_msg, + error_json={ + "message": error_msg, + "exception_type": type(exc).__name__, + "phase": "worker_execute", + "stack": "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ), + "context": {}, + }, + ) sink = get_trace_sink() sink.emit_span( @@ -173,7 +168,11 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: ) ) - return _worker_execute_result_from_output(output) + return WorkerExecuteResult( + success=output.success, + final_assistant_message=output.output, + error=None if output.success else output.output, + ) async def _persist_context_events( diff --git a/ergon_core/ergon_core/core/runtime/services/run_service.py b/ergon_core/ergon_core/core/runtime/services/run_service.py index 0d7679bb..bf84f42d 100644 --- a/ergon_core/ergon_core/core/runtime/services/run_service.py +++ b/ergon_core/ergon_core/core/runtime/services/run_service.py @@ -15,7 +15,7 @@ RunCleanupEvent, ) from ergon_core.core.runtime.events.task_events import WorkflowStartedEvent -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.settings import settings from ergon_core.core.utils import utcnow diff --git a/ergon_core/ergon_core/core/runtime/services/task_management_service.py b/ergon_core/ergon_core/core/runtime/services/task_management_service.py index c6d72e36..84e84871 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_management_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_management_service.py @@ -35,7 +35,7 @@ TaskCancelledEvent, TaskReadyEvent, ) -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.runtime.services.graph_dto import MutationMeta from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository from ergon_core.core.runtime.services.task_management_dto import ( diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_service.py b/ergon_core/ergon_core/core/runtime/services/workflow_service.py index 11fa2a0c..dd6008d0 100644 --- a/ergon_core/ergon_core/core/runtime/services/workflow_service.py +++ b/ergon_core/ergon_core/core/runtime/services/workflow_service.py @@ -15,7 +15,7 @@ RunResource, RunTaskExecution, ) -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager, DefaultSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager, DefaultSandboxManager from ergon_core.core.runtime.services.task_management_dto import ( AddSubtaskCommand, AddSubtaskResult, diff --git a/ergon_core/ergon_core/core/runtime/tracing.py b/ergon_core/ergon_core/core/runtime/tracing.py deleted file mode 100644 index 927e7d60..00000000 --- a/ergon_core/ergon_core/core/runtime/tracing.py +++ /dev/null @@ -1,572 +0,0 @@ -"""Tracing facade. - -Defines the TraceSink protocol and data classes that the runtime uses -to emit structured spans. The default sink is NoopTraceSink (discards -everything). When a real backend is wired in (OtelTraceSink), swap the -singleton returned by get_trace_sink(). - -Context factories at the bottom produce deterministic TraceContext -objects from run/task/execution/evaluator UUIDs so span trees are -reproducible across replays. - -Target span hierarchy (one trace per run, keyed by run_id):: - - workflow.execute (synthetic root) - │ cohort_id, instance_count - ├── workflow.start - ├── task.execute (per task) - │ instance_key - │ ├── sandbox.setup - │ ├── worker.execute - │ │ └── tool.{tool_name} (per tool call in GenerationTurn) - │ │ turn_index, tool_name, tool_call_id, has_result - │ ├── persist.outputs - │ │ resource_ids - │ └── evaluation.task (per evaluator) - │ └── evaluation.criterion (per criterion) - ├── task.propagate (per completion) - ├── communication.message (per ThreadMessage, optional) - │ thread_id, from_agent_id, to_agent_id, sequence_num - └── workflow.complete OR workflow.failed - -Every span stores relational IDs (run_id, task_id, execution_id, -evaluator_id) for PG lookup — not payload copies. -See otel_tracing_v2.md for full attribute schemas per span. -""" - -import hashlib -import json -import random -from contextlib import contextmanager -from contextvars import ContextVar -from datetime import UTC, datetime -from typing import Protocol -from uuid import UUID - -from opentelemetry import trace as otel_trace -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -try: - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter -except ImportError: - OTLPSpanExporter = None # type: ignore[assignment,misc] -from ergon_core.core.json_types import JsonObject, JsonValue -from ergon_core.core.settings import settings -from opentelemetry.trace import ( - NonRecordingSpan, - SpanContext, - Status, - StatusCode, - TraceFlags, -) -from opentelemetry.trace.propagation import set_span_in_context -from opentelemetry.trace.span import TraceState -from pydantic import BaseModel, Field - -TRACE_FLAGS_SAMPLED = 0x01 -_MAX_TRACE_ID = (1 << 128) - 1 -_MAX_SPAN_ID = (1 << 64) - 1 -_EMPTY_SPAN_ID = 0 - -_desired_trace_id: ContextVar[int | None] = ContextVar("desired_trace_id", default=None) -_desired_span_id: ContextVar[int | None] = ContextVar("desired_span_id", default=None) - -# --------------------------------------------------------------------------- -# Data classes -# --------------------------------------------------------------------------- - - -class TraceContext(BaseModel): - model_config = {"frozen": True} - - trace_id: int - span_id: int - parent_span_id: int | None = None - run_id: UUID | None = None - task_id: UUID | None = None - execution_id: UUID | None = None - evaluator_id: UUID | None = None - attributes: JsonObject = Field(default_factory=dict) - - -class SpanEvent(BaseModel): - model_config = {"frozen": True} - - name: str - timestamp: datetime - attributes: JsonObject = Field(default_factory=dict) - - -class CompletedSpan(BaseModel): - model_config = {"frozen": True} - - name: str - context: TraceContext - start_time: datetime - end_time: datetime - attributes: JsonObject = Field(default_factory=dict) - status_code: int | str = 0 - status_message: str | None = None - events: list[SpanEvent] = Field(default_factory=list) - - -# --------------------------------------------------------------------------- -# TraceSink protocol + noop implementation -# --------------------------------------------------------------------------- - - -class TraceSink(Protocol): - def emit_span(self, span: CompletedSpan) -> None: ... - - def add_event( - self, - context: TraceContext, - name: str, - attributes: JsonObject | None = None, - timestamp: datetime | None = None, - ) -> None: ... - - def child_context( - self, - parent: TraceContext, - *, - span_key: str, - run_id: UUID | None = None, - task_id: UUID | None = None, - execution_id: UUID | None = None, - evaluator_id: UUID | None = None, - attributes: JsonObject | None = None, - ) -> TraceContext: ... - - -class NoopTraceSink: - """Default sink that discards everything. Zero overhead.""" - - def emit_span(self, span: CompletedSpan) -> None: - pass - - def add_event( - self, - context: TraceContext, - name: str, - attributes: JsonObject | None = None, - timestamp: datetime | None = None, - ) -> None: - pass - - def child_context( - self, - parent: TraceContext, - *, - span_key: str, - run_id: UUID | None = None, - task_id: UUID | None = None, - execution_id: UUID | None = None, - evaluator_id: UUID | None = None, - attributes: JsonObject | None = None, - ) -> TraceContext: - child_span = span_id_from_key(str(parent.span_id), span_key) - return TraceContext( - trace_id=parent.trace_id, - span_id=child_span, - parent_span_id=parent.span_id, - run_id=parent.run_id if run_id is None else run_id, - task_id=parent.task_id if task_id is None else task_id, - execution_id=parent.execution_id if execution_id is None else execution_id, - evaluator_id=parent.evaluator_id if evaluator_id is None else evaluator_id, - attributes={} if attributes is None else attributes, - ) - - -# --------------------------------------------------------------------------- -# Attribute helpers -# --------------------------------------------------------------------------- - - -def truncate_text(value: str | None, max_length: int | None = None) -> str | None: - if value is None: - return None - limit = max_length or settings.otel_max_attribute_length - if len(value) <= limit: - return value - return f"{value[:limit]}...[truncated]" - - -def safe_json_attribute(value: JsonValue, max_length: int | None = None) -> str: - try: - serialized = json.dumps(value, default=str, separators=(",", ":")) - except (TypeError, ValueError): - serialized = str(value) - return truncate_text(serialized, max_length=max_length) or "" - - -def normalize_attributes(attributes: JsonObject | None) -> JsonObject: - if not attributes: - return {} - normalized: JsonObject = {} - for key, value in attributes.items(): - if value is None: - continue - if isinstance(value, (bool, int, float)): - normalized[key] = value - elif isinstance(value, str): - normalized[key] = truncate_text(value) - else: - normalized[key] = safe_json_attribute(value) - return normalized - - -def datetime_to_nanos(value: datetime) -> int: - if value.tzinfo is None: - value = value.replace(tzinfo=UTC) - return int(value.timestamp() * 1_000_000_000) - - -# --------------------------------------------------------------------------- -# Deterministic ID helpers -# --------------------------------------------------------------------------- - - -def trace_id_from_run_id(run_id: UUID) -> int: - """Derive a deterministic 128-bit trace ID from a run UUID.""" - return int(run_id.hex, 16) & _MAX_TRACE_ID - - -def span_id_from_key(*parts: str) -> int: - """Derive a deterministic 64-bit span ID from arbitrary string parts.""" - digest = hashlib.sha256(":".join(parts).encode()).digest()[:8] - return int.from_bytes(digest, "big") & _MAX_SPAN_ID or 1 - - -class DeterministicIdGenerator: - """OTEL ID generator that supports one-shot deterministic overrides.""" - - def generate_trace_id(self) -> int: - override = _desired_trace_id.get() - if override is not None: - return override - return random.getrandbits(128) - - def generate_span_id(self) -> int: - override = _desired_span_id.get() - if override is not None: - return override - return random.getrandbits(64) or 1 - - -@contextmanager -def _id_override(trace_id: int | None = None, span_id: int | None = None): - trace_token = _desired_trace_id.set(trace_id) if trace_id is not None else None - span_token = _desired_span_id.set(span_id) if span_id is not None else None - try: - yield - finally: - if span_token is not None: - _desired_span_id.reset(span_token) - if trace_token is not None: - _desired_trace_id.reset(trace_token) - - -# --------------------------------------------------------------------------- -# OtelTraceSink -# --------------------------------------------------------------------------- - - -class OtelTraceSink: - """OTEL-backed sink that exports spans via OTLP/gRPC.""" - - def __init__(self) -> None: - provider = TracerProvider( - resource=Resource.create({"service.name": settings.otel_service_name}), - id_generator=DeterministicIdGenerator(), - ) - exporter = OTLPSpanExporter( - endpoint=settings.otel_exporter_otlp_endpoint, - insecure=settings.otel_exporter_otlp_insecure, - ) - provider.add_span_processor(BatchSpanProcessor(exporter)) - otel_trace.set_tracer_provider(provider) - - self._provider: TracerProvider = provider - self._tracer = otel_trace.get_tracer(settings.otel_service_name) - - def child_context( - self, - parent: TraceContext, - *, - span_key: str, - run_id: UUID | None = None, - task_id: UUID | None = None, - execution_id: UUID | None = None, - evaluator_id: UUID | None = None, - attributes: JsonObject | None = None, - ) -> TraceContext: - return TraceContext( - trace_id=parent.trace_id, - span_id=span_id_from_key(str(parent.trace_id), str(parent.span_id), span_key), - parent_span_id=parent.span_id, - run_id=run_id if run_id is not None else parent.run_id, - task_id=task_id if task_id is not None else parent.task_id, - execution_id=execution_id if execution_id is not None else parent.execution_id, - evaluator_id=evaluator_id if evaluator_id is not None else parent.evaluator_id, - attributes=attributes or {}, - ) - - def add_event( - self, - context: TraceContext, - name: str, - attributes: JsonObject | None = None, - timestamp: datetime | None = None, - ) -> None: - now = timestamp or datetime.now(UTC) - span = CompletedSpan( - name=f"{name}.event", - context=context, - start_time=now, - end_time=now, - attributes=attributes or {}, - events=[SpanEvent(name=name, timestamp=now, attributes=attributes or {})], - ) - self.emit_span(span) - - def emit_span(self, span: CompletedSpan) -> None: - parent_ctx = None - if span.context.parent_span_id not in (None, _EMPTY_SPAN_ID): - span_context = SpanContext( - trace_id=span.context.trace_id, - span_id=span.context.parent_span_id, - is_remote=False, - trace_flags=TraceFlags(TRACE_FLAGS_SAMPLED), - trace_state=TraceState(), - ) - parent_ctx = set_span_in_context(NonRecordingSpan(span_context)) - - start_time = datetime_to_nanos(span.start_time) - end_time = datetime_to_nanos(span.end_time) - attrs = normalize_attributes({**span.context.attributes, **span.attributes}) - - with _id_override( - trace_id=span.context.trace_id if span.context.parent_span_id is None else None, - span_id=span.context.span_id, - ): - sdk_span = self._tracer.start_span( - span.name, - context=parent_ctx, - attributes=attrs, - start_time=start_time, - ) - - if str(span.status_code).lower() == "error": - sdk_span.set_status(Status(StatusCode.ERROR, span.status_message)) - else: - sdk_span.set_status(Status(StatusCode.OK)) - - for event in span.events: - sdk_span.add_event( - event.name, - attributes=normalize_attributes(event.attributes), - timestamp=datetime_to_nanos(event.timestamp), - ) - - sdk_span.end(end_time=end_time) - - -# --------------------------------------------------------------------------- -# Process-wide sink -# --------------------------------------------------------------------------- - - -def _create_sink() -> TraceSink: - if not settings.otel_traces_enabled: - return NoopTraceSink() - # The operator explicitly opted in to OTEL. Refuse to silently downgrade - # to a no-op sink — that has caused real "where are my traces?" debugging - # sessions. Surface the construction error so misconfiguration is loud. - return OtelTraceSink() - - -_sink: TraceSink = _create_sink() - - -def get_trace_sink() -> TraceSink: - """Return the process-wide trace sink. - - Each process (uvicorn worker, CLI invocation, test runner) gets its own - sink created at import time. No locking needed — OTEL is stateless - per-process and the collector handles fan-in from multiple exporters. - """ - return _sink - - -# --------------------------------------------------------------------------- -# Context factories -# --------------------------------------------------------------------------- - - -def workflow_root_context(run_id: UUID) -> TraceContext: - tid = trace_id_from_run_id(run_id) - return TraceContext( - trace_id=tid, - span_id=span_id_from_key("workflow", str(run_id)), - run_id=run_id, - ) - - -def workflow_start_context(run_id: UUID) -> TraceContext: - root = workflow_root_context(run_id) - return TraceContext( - trace_id=root.trace_id, - span_id=span_id_from_key("workflow_start", str(run_id)), - parent_span_id=root.span_id, - run_id=run_id, - ) - - -def task_execute_context(run_id: UUID, task_id: UUID) -> TraceContext: - root = workflow_root_context(run_id) - return TraceContext( - trace_id=root.trace_id, - span_id=span_id_from_key("task_execute", str(run_id), str(task_id)), - parent_span_id=root.span_id, - run_id=run_id, - task_id=task_id, - ) - - -def sandbox_setup_context(run_id: UUID, task_id: UUID) -> TraceContext: - parent = task_execute_context(run_id, task_id) - return TraceContext( - trace_id=parent.trace_id, - span_id=span_id_from_key("sandbox_setup", str(run_id), str(task_id)), - parent_span_id=parent.span_id, - run_id=run_id, - task_id=task_id, - ) - - -def worker_execute_context( - run_id: UUID, - task_id: UUID, - execution_id: UUID, -) -> TraceContext: - parent = task_execute_context(run_id, task_id) - return TraceContext( - trace_id=parent.trace_id, - span_id=span_id_from_key( - "worker_execute", - str(run_id), - str(task_id), - str(execution_id), - ), - parent_span_id=parent.span_id, - run_id=run_id, - task_id=task_id, - execution_id=execution_id, - ) - - -def persist_outputs_context( - run_id: UUID, - task_id: UUID, - execution_id: UUID, -) -> TraceContext: - parent = task_execute_context(run_id, task_id) - return TraceContext( - trace_id=parent.trace_id, - span_id=span_id_from_key( - "persist_outputs", - str(run_id), - str(task_id), - str(execution_id), - ), - parent_span_id=parent.span_id, - run_id=run_id, - task_id=task_id, - execution_id=execution_id, - ) - - -def task_propagate_context(run_id: UUID, task_id: UUID) -> TraceContext: - root = workflow_root_context(run_id) - return TraceContext( - trace_id=root.trace_id, - span_id=span_id_from_key("task_propagate", str(run_id), str(task_id)), - parent_span_id=root.span_id, - run_id=run_id, - task_id=task_id, - ) - - -def workflow_complete_context(run_id: UUID) -> TraceContext: - root = workflow_root_context(run_id) - return TraceContext( - trace_id=root.trace_id, - span_id=span_id_from_key("workflow_complete", str(run_id)), - parent_span_id=root.span_id, - run_id=run_id, - ) - - -def workflow_failed_context(run_id: UUID) -> TraceContext: - root = workflow_root_context(run_id) - return TraceContext( - trace_id=root.trace_id, - span_id=span_id_from_key("workflow_failed", str(run_id)), - parent_span_id=root.span_id, - run_id=run_id, - ) - - -def evaluation_task_context( - run_id: UUID, - task_id: UUID, - execution_id: UUID, - evaluator_id: UUID, -) -> TraceContext: - parent = task_execute_context(run_id, task_id) - return TraceContext( - trace_id=parent.trace_id, - span_id=span_id_from_key( - "evaluation_task", - str(run_id), - str(task_id), - str(execution_id), - str(evaluator_id), - ), - parent_span_id=parent.span_id, - run_id=run_id, - task_id=task_id, - execution_id=execution_id, - evaluator_id=evaluator_id, - ) - - -def evaluation_criterion_context( - run_id: UUID, - task_id: UUID, - execution_id: UUID, - evaluator_id: UUID, - stage_idx: int, - criterion_idx: int, -) -> TraceContext: - parent = evaluation_task_context(run_id, task_id, execution_id, evaluator_id) - return TraceContext( - trace_id=parent.trace_id, - span_id=span_id_from_key( - "evaluation_criterion", - str(run_id), - str(task_id), - str(execution_id), - str(evaluator_id), - str(stage_idx), - str(criterion_idx), - ), - parent_span_id=parent.span_id, - run_id=run_id, - task_id=task_id, - execution_id=execution_id, - evaluator_id=evaluator_id, - ) diff --git a/ergon_core/ergon_core/core/runtime/tracing/__init__.py b/ergon_core/ergon_core/core/runtime/tracing/__init__.py new file mode 100644 index 00000000..82db1111 --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/tracing/__init__.py @@ -0,0 +1,85 @@ +"""Tracing facade. + +The runtime emits structured spans through this package while keeping the +existing public import path stable: + + from ergon_core.core.runtime.tracing import get_trace_sink + +Target span hierarchy (one trace per run, keyed by run_id):: + + workflow.execute (synthetic root) + | cohort_id, instance_count + +-- workflow.start + +-- task.execute (per task) + | instance_key + | +-- sandbox.setup + | +-- worker.execute + | | +-- tool.{tool_name} (per tool call in GenerationTurn) + | +-- persist.outputs + | +-- evaluation.task (per evaluator) + | +-- evaluation.criterion (per criterion) + +-- task.propagate (per completion) + +-- communication.message (per ThreadMessage, optional) + +-- workflow.complete OR workflow.failed + +Every span stores relational IDs (run_id, task_id, execution_id, evaluator_id) +for PG lookup, not payload copies. See otel_tracing_v2.md for full attribute +schemas per span. +""" + +from ergon_core.core.runtime.tracing.attributes import ( + datetime_to_nanos, + normalize_attributes, + safe_json_attribute, + truncate_text, +) +from ergon_core.core.runtime.tracing.contexts import ( + evaluation_criterion_context, + evaluation_task_context, + persist_outputs_context, + sandbox_setup_context, + task_execute_context, + task_propagate_context, + workflow_complete_context, + workflow_failed_context, + workflow_root_context, + workflow_start_context, + worker_execute_context, +) +from ergon_core.core.runtime.tracing.ids import ( + DeterministicIdGenerator, + span_id_from_key, + trace_id_from_run_id, +) +from ergon_core.core.runtime.tracing.noop import NoopTraceSink +from ergon_core.core.runtime.tracing.otel import OtelTraceSink +from ergon_core.core.runtime.tracing.sinks import get_trace_sink +from ergon_core.core.runtime.tracing.types import CompletedSpan, SpanEvent, TraceContext, TraceSink + +__all__ = [ + "CompletedSpan", + "DeterministicIdGenerator", + "NoopTraceSink", + "OtelTraceSink", + "SpanEvent", + "TraceContext", + "TraceSink", + "datetime_to_nanos", + "evaluation_criterion_context", + "evaluation_task_context", + "get_trace_sink", + "normalize_attributes", + "persist_outputs_context", + "safe_json_attribute", + "sandbox_setup_context", + "span_id_from_key", + "task_execute_context", + "task_propagate_context", + "trace_id_from_run_id", + "truncate_text", + "workflow_complete_context", + "workflow_failed_context", + "workflow_root_context", + "workflow_start_context", + "worker_execute_context", +] diff --git a/ergon_core/ergon_core/core/runtime/tracing/attributes.py b/ergon_core/ergon_core/core/runtime/tracing/attributes.py new file mode 100644 index 00000000..1775b2cd --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/tracing/attributes.py @@ -0,0 +1,46 @@ +"""Helpers for serializing values into OTEL-safe attributes.""" + +import json +from datetime import UTC, datetime + +from ergon_core.core.json_types import JsonObject, JsonValue +from ergon_core.core.settings import settings + + +def truncate_text(value: str | None, max_length: int | None = None) -> str | None: + if value is None: + return None + limit = max_length or settings.otel_max_attribute_length + if len(value) <= limit: + return value + return f"{value[:limit]}...[truncated]" + + +def safe_json_attribute(value: JsonValue, max_length: int | None = None) -> str: + try: + serialized = json.dumps(value, default=str, separators=(",", ":")) + except (TypeError, ValueError): + serialized = str(value) + return truncate_text(serialized, max_length=max_length) or "" + + +def normalize_attributes(attributes: JsonObject | None) -> JsonObject: + if not attributes: + return {} + normalized: JsonObject = {} + for key, value in attributes.items(): + if value is None: + continue + if isinstance(value, (bool, int, float)): + normalized[key] = value + elif isinstance(value, str): + normalized[key] = truncate_text(value) + else: + normalized[key] = safe_json_attribute(value) + return normalized + + +def datetime_to_nanos(value: datetime) -> int: + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return int(value.timestamp() * 1_000_000_000) diff --git a/ergon_core/ergon_core/core/runtime/tracing/contexts.py b/ergon_core/ergon_core/core/runtime/tracing/contexts.py new file mode 100644 index 00000000..baa01720 --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/tracing/contexts.py @@ -0,0 +1,177 @@ +"""Runtime trace context factories. + +Context factories produce deterministic ``TraceContext`` objects from +run/task/execution/evaluator UUIDs so span trees are reproducible across +replays. +""" + +from uuid import UUID + +from ergon_core.core.runtime.tracing.ids import span_id_from_key, trace_id_from_run_id +from ergon_core.core.runtime.tracing.types import TraceContext + + +def workflow_root_context(run_id: UUID) -> TraceContext: + tid = trace_id_from_run_id(run_id) + return TraceContext( + trace_id=tid, + span_id=span_id_from_key("workflow", str(run_id)), + run_id=run_id, + ) + + +def workflow_start_context(run_id: UUID) -> TraceContext: + root = workflow_root_context(run_id) + return TraceContext( + trace_id=root.trace_id, + span_id=span_id_from_key("workflow_start", str(run_id)), + parent_span_id=root.span_id, + run_id=run_id, + ) + + +def task_execute_context(run_id: UUID, task_id: UUID) -> TraceContext: + root = workflow_root_context(run_id) + return TraceContext( + trace_id=root.trace_id, + span_id=span_id_from_key("task_execute", str(run_id), str(task_id)), + parent_span_id=root.span_id, + run_id=run_id, + task_id=task_id, + ) + + +def sandbox_setup_context(run_id: UUID, task_id: UUID) -> TraceContext: + parent = task_execute_context(run_id, task_id) + return TraceContext( + trace_id=parent.trace_id, + span_id=span_id_from_key("sandbox_setup", str(run_id), str(task_id)), + parent_span_id=parent.span_id, + run_id=run_id, + task_id=task_id, + ) + + +def worker_execute_context( + run_id: UUID, + task_id: UUID, + execution_id: UUID, +) -> TraceContext: + parent = task_execute_context(run_id, task_id) + return TraceContext( + trace_id=parent.trace_id, + span_id=span_id_from_key( + "worker_execute", + str(run_id), + str(task_id), + str(execution_id), + ), + parent_span_id=parent.span_id, + run_id=run_id, + task_id=task_id, + execution_id=execution_id, + ) + + +def persist_outputs_context( + run_id: UUID, + task_id: UUID, + execution_id: UUID, +) -> TraceContext: + parent = task_execute_context(run_id, task_id) + return TraceContext( + trace_id=parent.trace_id, + span_id=span_id_from_key( + "persist_outputs", + str(run_id), + str(task_id), + str(execution_id), + ), + parent_span_id=parent.span_id, + run_id=run_id, + task_id=task_id, + execution_id=execution_id, + ) + + +def task_propagate_context(run_id: UUID, task_id: UUID) -> TraceContext: + root = workflow_root_context(run_id) + return TraceContext( + trace_id=root.trace_id, + span_id=span_id_from_key("task_propagate", str(run_id), str(task_id)), + parent_span_id=root.span_id, + run_id=run_id, + task_id=task_id, + ) + + +def workflow_complete_context(run_id: UUID) -> TraceContext: + root = workflow_root_context(run_id) + return TraceContext( + trace_id=root.trace_id, + span_id=span_id_from_key("workflow_complete", str(run_id)), + parent_span_id=root.span_id, + run_id=run_id, + ) + + +def workflow_failed_context(run_id: UUID) -> TraceContext: + root = workflow_root_context(run_id) + return TraceContext( + trace_id=root.trace_id, + span_id=span_id_from_key("workflow_failed", str(run_id)), + parent_span_id=root.span_id, + run_id=run_id, + ) + + +def evaluation_task_context( + run_id: UUID, + task_id: UUID, + execution_id: UUID, + evaluator_id: UUID, +) -> TraceContext: + parent = task_execute_context(run_id, task_id) + return TraceContext( + trace_id=parent.trace_id, + span_id=span_id_from_key( + "evaluation_task", + str(run_id), + str(task_id), + str(execution_id), + str(evaluator_id), + ), + parent_span_id=parent.span_id, + run_id=run_id, + task_id=task_id, + execution_id=execution_id, + evaluator_id=evaluator_id, + ) + + +def evaluation_criterion_context( + run_id: UUID, + task_id: UUID, + execution_id: UUID, + evaluator_id: UUID, + stage_idx: int, + criterion_idx: int, +) -> TraceContext: + parent = evaluation_task_context(run_id, task_id, execution_id, evaluator_id) + return TraceContext( + trace_id=parent.trace_id, + span_id=span_id_from_key( + "evaluation_criterion", + str(run_id), + str(task_id), + str(execution_id), + str(evaluator_id), + str(stage_idx), + str(criterion_idx), + ), + parent_span_id=parent.span_id, + run_id=run_id, + task_id=task_id, + execution_id=execution_id, + evaluator_id=evaluator_id, + ) diff --git a/ergon_core/ergon_core/core/runtime/tracing/ids.py b/ergon_core/ergon_core/core/runtime/tracing/ids.py new file mode 100644 index 00000000..d01d9c0f --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/tracing/ids.py @@ -0,0 +1,56 @@ +"""Deterministic trace and span ID helpers.""" + +import hashlib +import random +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Iterator +from uuid import UUID + +TRACE_FLAGS_SAMPLED = 0x01 +MAX_TRACE_ID = (1 << 128) - 1 +MAX_SPAN_ID = (1 << 64) - 1 +EMPTY_SPAN_ID = 0 + +_desired_trace_id: ContextVar[int | None] = ContextVar("desired_trace_id", default=None) +_desired_span_id: ContextVar[int | None] = ContextVar("desired_span_id", default=None) + + +def trace_id_from_run_id(run_id: UUID) -> int: + """Derive a deterministic 128-bit trace ID from a run UUID.""" + return int(run_id.hex, 16) & MAX_TRACE_ID + + +def span_id_from_key(*parts: str) -> int: + """Derive a deterministic 64-bit span ID from arbitrary string parts.""" + digest = hashlib.sha256(":".join(parts).encode()).digest()[:8] + return int.from_bytes(digest, "big") & MAX_SPAN_ID or 1 + + +class DeterministicIdGenerator: + """OTEL ID generator that supports one-shot deterministic overrides.""" + + def generate_trace_id(self) -> int: + override = _desired_trace_id.get() + if override is not None: + return override + return random.getrandbits(128) + + def generate_span_id(self) -> int: + override = _desired_span_id.get() + if override is not None: + return override + return random.getrandbits(64) or 1 + + +@contextmanager +def id_override(trace_id: int | None = None, span_id: int | None = None) -> Iterator[None]: + trace_token = _desired_trace_id.set(trace_id) if trace_id is not None else None + span_token = _desired_span_id.set(span_id) if span_id is not None else None + try: + yield + finally: + if span_token is not None: + _desired_span_id.reset(span_token) + if trace_token is not None: + _desired_trace_id.reset(trace_token) diff --git a/ergon_core/ergon_core/core/runtime/tracing/noop.py b/ergon_core/ergon_core/core/runtime/tracing/noop.py new file mode 100644 index 00000000..b18809b8 --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/tracing/noop.py @@ -0,0 +1,47 @@ +"""No-op tracing sink.""" + +from datetime import datetime +from uuid import UUID + +from ergon_core.core.json_types import JsonObject +from ergon_core.core.runtime.tracing.ids import span_id_from_key +from ergon_core.core.runtime.tracing.types import CompletedSpan, TraceContext + + +class NoopTraceSink: + """Default sink that discards everything.""" + + def emit_span(self, span: CompletedSpan) -> None: + pass + + def add_event( + self, + context: TraceContext, + name: str, + attributes: JsonObject | None = None, + timestamp: datetime | None = None, + ) -> None: + pass + + def child_context( + self, + parent: TraceContext, + *, + span_key: str, + run_id: UUID | None = None, + task_id: UUID | None = None, + execution_id: UUID | None = None, + evaluator_id: UUID | None = None, + attributes: JsonObject | None = None, + ) -> TraceContext: + child_span = span_id_from_key(str(parent.span_id), span_key) + return TraceContext( + trace_id=parent.trace_id, + span_id=child_span, + parent_span_id=parent.span_id, + run_id=parent.run_id if run_id is None else run_id, + task_id=parent.task_id if task_id is None else task_id, + execution_id=parent.execution_id if execution_id is None else execution_id, + evaluator_id=parent.evaluator_id if evaluator_id is None else evaluator_id, + attributes={} if attributes is None else attributes, + ) diff --git a/ergon_core/ergon_core/core/runtime/tracing/otel.py b/ergon_core/ergon_core/core/runtime/tracing/otel.py new file mode 100644 index 00000000..51b66dc4 --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/tracing/otel.py @@ -0,0 +1,135 @@ +"""OpenTelemetry tracing sink.""" + +from datetime import UTC, datetime +from uuid import UUID + +from opentelemetry import trace as otel_trace +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import ( + NonRecordingSpan, + SpanContext, + Status, + StatusCode, + TraceFlags, +) +from opentelemetry.trace.propagation import set_span_in_context +from opentelemetry.trace.span import TraceState + +try: + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +except ImportError: + OTLPSpanExporter = None # type: ignore[assignment,misc] + +from ergon_core.core.json_types import JsonObject +from ergon_core.core.runtime.tracing.attributes import datetime_to_nanos, normalize_attributes +from ergon_core.core.runtime.tracing.ids import ( + EMPTY_SPAN_ID, + TRACE_FLAGS_SAMPLED, + DeterministicIdGenerator, + id_override, + span_id_from_key, +) +from ergon_core.core.runtime.tracing.types import CompletedSpan, SpanEvent, TraceContext +from ergon_core.core.settings import settings + + +class OtelTraceSink: + """OTEL-backed sink that exports spans via OTLP/gRPC.""" + + def __init__(self) -> None: + provider = TracerProvider( + resource=Resource.create({"service.name": settings.otel_service_name}), + id_generator=DeterministicIdGenerator(), + ) + exporter = OTLPSpanExporter( + endpoint=settings.otel_exporter_otlp_endpoint, + insecure=settings.otel_exporter_otlp_insecure, + ) + provider.add_span_processor(BatchSpanProcessor(exporter)) + otel_trace.set_tracer_provider(provider) + + self._provider: TracerProvider = provider + self._tracer = otel_trace.get_tracer(settings.otel_service_name) + + def child_context( + self, + parent: TraceContext, + *, + span_key: str, + run_id: UUID | None = None, + task_id: UUID | None = None, + execution_id: UUID | None = None, + evaluator_id: UUID | None = None, + attributes: JsonObject | None = None, + ) -> TraceContext: + return TraceContext( + trace_id=parent.trace_id, + span_id=span_id_from_key(str(parent.trace_id), str(parent.span_id), span_key), + parent_span_id=parent.span_id, + run_id=run_id if run_id is not None else parent.run_id, + task_id=task_id if task_id is not None else parent.task_id, + execution_id=execution_id if execution_id is not None else parent.execution_id, + evaluator_id=evaluator_id if evaluator_id is not None else parent.evaluator_id, + attributes=attributes or {}, + ) + + def add_event( + self, + context: TraceContext, + name: str, + attributes: JsonObject | None = None, + timestamp: datetime | None = None, + ) -> None: + now = timestamp or datetime.now(UTC) + span = CompletedSpan( + name=f"{name}.event", + context=context, + start_time=now, + end_time=now, + attributes=attributes or {}, + events=[SpanEvent(name=name, timestamp=now, attributes=attributes or {})], + ) + self.emit_span(span) + + def emit_span(self, span: CompletedSpan) -> None: + parent_ctx = None + if span.context.parent_span_id not in (None, EMPTY_SPAN_ID): + span_context = SpanContext( + trace_id=span.context.trace_id, + span_id=span.context.parent_span_id, + is_remote=False, + trace_flags=TraceFlags(TRACE_FLAGS_SAMPLED), + trace_state=TraceState(), + ) + parent_ctx = set_span_in_context(NonRecordingSpan(span_context)) + + start_time = datetime_to_nanos(span.start_time) + end_time = datetime_to_nanos(span.end_time) + attrs = normalize_attributes({**span.context.attributes, **span.attributes}) + + with id_override( + trace_id=span.context.trace_id if span.context.parent_span_id is None else None, + span_id=span.context.span_id, + ): + sdk_span = self._tracer.start_span( + span.name, + context=parent_ctx, + attributes=attrs, + start_time=start_time, + ) + + if str(span.status_code).lower() == "error": + sdk_span.set_status(Status(StatusCode.ERROR, span.status_message)) + else: + sdk_span.set_status(Status(StatusCode.OK)) + + for event in span.events: + sdk_span.add_event( + event.name, + attributes=normalize_attributes(event.attributes), + timestamp=datetime_to_nanos(event.timestamp), + ) + + sdk_span.end(end_time=end_time) diff --git a/ergon_core/ergon_core/core/runtime/tracing/sinks.py b/ergon_core/ergon_core/core/runtime/tracing/sinks.py new file mode 100644 index 00000000..34607a4b --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/tracing/sinks.py @@ -0,0 +1,27 @@ +"""Process-wide trace sink factory.""" + +from ergon_core.core.runtime.tracing.noop import NoopTraceSink +from ergon_core.core.runtime.tracing.otel import OtelTraceSink +from ergon_core.core.runtime.tracing.types import TraceSink +from ergon_core.core.settings import settings + + +def _create_sink() -> TraceSink: + if not settings.otel_traces_enabled: + return NoopTraceSink() + # The operator explicitly opted in to OTEL. Refuse to silently downgrade + # to a no-op sink, so trace exporter misconfiguration is loud. + return OtelTraceSink() + + +_sink: TraceSink = _create_sink() + + +def get_trace_sink() -> TraceSink: + """Return the process-wide trace sink. + + Each process (uvicorn worker, CLI invocation, test runner) gets its own + sink created at import time. No locking needed; OTEL is stateless + per-process and the collector handles fan-in from multiple exporters. + """ + return _sink diff --git a/ergon_core/ergon_core/core/runtime/tracing/types.py b/ergon_core/ergon_core/core/runtime/tracing/types.py new file mode 100644 index 00000000..2fd6cc6c --- /dev/null +++ b/ergon_core/ergon_core/core/runtime/tracing/types.py @@ -0,0 +1,67 @@ +"""Tracing data contracts.""" + +from datetime import datetime +from typing import Protocol +from uuid import UUID + +from pydantic import BaseModel, Field + +from ergon_core.core.json_types import JsonObject + + +class TraceContext(BaseModel): + model_config = {"frozen": True} + + trace_id: int + span_id: int + parent_span_id: int | None = None + run_id: UUID | None = None + task_id: UUID | None = None + execution_id: UUID | None = None + evaluator_id: UUID | None = None + attributes: JsonObject = Field(default_factory=dict) + + +class SpanEvent(BaseModel): + model_config = {"frozen": True} + + name: str + timestamp: datetime + attributes: JsonObject = Field(default_factory=dict) + + +class CompletedSpan(BaseModel): + model_config = {"frozen": True} + + name: str + context: TraceContext + start_time: datetime + end_time: datetime + attributes: JsonObject = Field(default_factory=dict) + status_code: int | str = 0 + status_message: str | None = None + events: list[SpanEvent] = Field(default_factory=list) + + +class TraceSink(Protocol): + def emit_span(self, span: CompletedSpan) -> None: ... + + def add_event( + self, + context: TraceContext, + name: str, + attributes: JsonObject | None = None, + timestamp: datetime | None = None, + ) -> None: ... + + def child_context( + self, + parent: TraceContext, + *, + span_key: str, + run_id: UUID | None = None, + task_id: UUID | None = None, + execution_id: UUID | None = None, + evaluator_id: UUID | None = None, + attributes: JsonObject | None = None, + ) -> TraceContext: ... diff --git a/ergon_core/ergon_core/core/providers/sandbox/__init__.py b/ergon_core/ergon_core/core/sandbox/__init__.py similarity index 72% rename from ergon_core/ergon_core/core/providers/sandbox/__init__.py rename to ergon_core/ergon_core/core/sandbox/__init__.py index 6a0a5e62..288b875c 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/__init__.py +++ b/ergon_core/ergon_core/core/sandbox/__init__.py @@ -1,7 +1,7 @@ """Sandbox management: provisioning, file I/O, lifecycle. Import concrete modules directly, for example -``ergon_core.core.providers.sandbox.manager``. Keeping this package initializer +``ergon_core.core.sandbox.manager``. Keeping this package initializer lightweight avoids import cycles between telemetry models and API DTO modules. """ diff --git a/ergon_core/ergon_core/core/providers/sandbox/errors.py b/ergon_core/ergon_core/core/sandbox/errors.py similarity index 100% rename from ergon_core/ergon_core/core/providers/sandbox/errors.py rename to ergon_core/ergon_core/core/sandbox/errors.py diff --git a/ergon_core/ergon_core/core/providers/sandbox/event_sink.py b/ergon_core/ergon_core/core/sandbox/event_sink.py similarity index 100% rename from ergon_core/ergon_core/core/providers/sandbox/event_sink.py rename to ergon_core/ergon_core/core/sandbox/event_sink.py diff --git a/ergon_core/ergon_core/core/providers/sandbox/instrumentation.py b/ergon_core/ergon_core/core/sandbox/instrumentation.py similarity index 98% rename from ergon_core/ergon_core/core/providers/sandbox/instrumentation.py rename to ergon_core/ergon_core/core/sandbox/instrumentation.py index 30411c08..e4c0e307 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/instrumentation.py +++ b/ergon_core/ergon_core/core/sandbox/instrumentation.py @@ -13,8 +13,8 @@ except ImportError: CommandExitException = Exception # type: ignore[assignment,misc] -from ergon_core.core.providers.sandbox.event_sink import SandboxEventSink -from ergon_core.core.providers.sandbox.utils import ( +from ergon_core.core.sandbox.event_sink import SandboxEventSink +from ergon_core.core.sandbox.utils import ( _truncate, bytes_length, coerce_text, diff --git a/ergon_core/ergon_core/core/providers/sandbox/lifecycle.py b/ergon_core/ergon_core/core/sandbox/lifecycle.py similarity index 96% rename from ergon_core/ergon_core/core/providers/sandbox/lifecycle.py rename to ergon_core/ergon_core/core/sandbox/lifecycle.py index 33595810..c6a862c5 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/lifecycle.py +++ b/ergon_core/ergon_core/core/sandbox/lifecycle.py @@ -32,7 +32,7 @@ async def terminate_sandbox_by_id(sandbox_id: str | None) -> SandboxTerminationR try: # reason: avoid import cycle between sandbox manager/event sink and telemetry models. - from ergon_core.core.providers.sandbox.manager import ( + from ergon_core.core.sandbox.manager import ( BaseSandboxManager, ) diff --git a/ergon_core/ergon_core/core/providers/sandbox/manager.py b/ergon_core/ergon_core/core/sandbox/manager.py similarity index 99% rename from ergon_core/ergon_core/core/providers/sandbox/manager.py rename to ergon_core/ergon_core/core/sandbox/manager.py index 7bbab2ab..abd15641 100644 --- a/ergon_core/ergon_core/core/providers/sandbox/manager.py +++ b/ergon_core/ergon_core/core/sandbox/manager.py @@ -8,12 +8,12 @@ from typing import ClassVar, Protocol, runtime_checkable from uuid import UUID -from ergon_core.core.providers.sandbox.errors import SandboxExpiredError -from ergon_core.core.providers.sandbox.event_sink import ( +from ergon_core.core.sandbox.errors import SandboxExpiredError +from ergon_core.core.sandbox.event_sink import ( NoopSandboxEventSink, SandboxEventSink, ) -from ergon_core.core.providers.sandbox.utils import _truncate, coerce_text +from ergon_core.core.sandbox.utils import _truncate, coerce_text from ergon_core.core.settings import settings from pydantic import BaseModel diff --git a/ergon_core/ergon_core/core/providers/sandbox/resource_publisher.py b/ergon_core/ergon_core/core/sandbox/resource_publisher.py similarity index 100% rename from ergon_core/ergon_core/core/providers/sandbox/resource_publisher.py rename to ergon_core/ergon_core/core/sandbox/resource_publisher.py diff --git a/ergon_core/ergon_core/core/providers/sandbox/utils.py b/ergon_core/ergon_core/core/sandbox/utils.py similarity index 100% rename from ergon_core/ergon_core/core/providers/sandbox/utils.py rename to ergon_core/ergon_core/core/sandbox/utils.py diff --git a/ergon_core/ergon_core/test_support/sandbox/stub_manager.py b/ergon_core/ergon_core/test_support/sandbox/stub_manager.py index 1674ddb3..eed4676e 100644 --- a/ergon_core/ergon_core/test_support/sandbox/stub_manager.py +++ b/ergon_core/ergon_core/test_support/sandbox/stub_manager.py @@ -4,7 +4,7 @@ from typing import cast from uuid import UUID -from ergon_core.core.providers.sandbox.manager import AsyncSandbox, BaseSandboxManager +from ergon_core.core.sandbox.manager import AsyncSandbox, BaseSandboxManager from ergon_core.test_support.sandbox.sentinel import STUB_SANDBOX_PREFIX logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/sandbox.py b/ergon_core/ergon_core/test_support/smoke_fixtures/sandbox.py index 0eb556ac..65a28726 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/sandbox.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/sandbox.py @@ -12,7 +12,7 @@ from typing import cast from uuid import UUID -from ergon_core.core.providers.sandbox.manager import AsyncSandbox, BaseSandboxManager +from ergon_core.core.sandbox.manager import AsyncSandbox, BaseSandboxManager from ergon_core.core.settings import settings from pydantic import BaseModel diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py index cd653656..b7343212 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py @@ -29,7 +29,7 @@ from ergon_core.api.results import WorkerOutput from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.providers.sandbox.instrumentation import InstrumentedSandbox +from ergon_core.core.sandbox.instrumentation import InstrumentedSandbox from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest from ergon_core.core.runtime.services.communication_service import ( communication_service, diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/subworker.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/subworker.py index e8dfb88f..9fccef3b 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/subworker.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/subworker.py @@ -21,7 +21,7 @@ from typing import Protocol, runtime_checkable -from ergon_core.core.providers.sandbox.manager import AsyncSandbox +from ergon_core.core.sandbox.manager import AsyncSandbox from pydantic import BaseModel diff --git a/pyproject.toml b/pyproject.toml index fff065e9..1faf9719 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,7 +112,7 @@ invalid-assignment = "warn" # invalid-assignment: try/except ImportError fallbacks (AsyncSandbox = None, # CommandExitException = Exception) when e2b SDK is unavailable. [[tool.ty.overrides]] -include = ["**/providers/sandbox/**"] +include = ["ergon_core/ergon_core/core/sandbox/**"] [tool.ty.overrides.rules] invalid-argument-type = "warn" unresolved-attribute = "warn" diff --git a/scripts/spike_openrouter_reasoning.py b/scripts/spike_openrouter_reasoning.py new file mode 100644 index 00000000..c8ce9abb --- /dev/null +++ b/scripts/spike_openrouter_reasoning.py @@ -0,0 +1,141 @@ +"""Spike reasoning settings and streamed thinking events. + +Usage: + uv run python scripts/spike_openrouter_reasoning.py + uv run python scripts/spike_openrouter_reasoning.py --model openrouter:anthropic/claude-opus-4.7 + uv run python scripts/spike_openrouter_reasoning.py --model anthropic:claude-opus-4.7 + +The script always prints Ergon's resolved model settings. If OPENROUTER_API_KEY +is available, it also runs one tiny PydanticAI streaming request and reports +whether ThinkingPart / ThinkingPartDelta events are surfaced. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +from collections import Counter +from typing import Any + +# Register production model backends before resolving OpenRouter targets. +import ergon_builtins.registry # noqa: F401 +from ergon_builtins.models.resolution import resolve_model_target +from pydantic_ai import Agent +from pydantic_ai.messages import ( + PartDeltaEvent, + PartEndEvent, + PartStartEvent, + TextPartDelta, + ThinkingPart, + ThinkingPartDelta, +) + + +def _thinking_content(part: ThinkingPart) -> str: + if part.content: + return part.content + details = part.provider_details + if isinstance(details, dict): + raw_content = details.get("raw_content") + if isinstance(raw_content, str): + return raw_content + return "" + + +async def _run_stream(model: str, prompt: str) -> None: + resolved = resolve_model_target(model) + print(f"resolved.model={resolved.model!r}") + print(f"resolved.capture_model_settings={resolved.capture_model_settings!r}") + + required_key = _required_api_key_name(model) + if required_key and not os.environ.get(required_key): + print(f"{required_key} is not set; skipping live call.") + return + + agent: Agent[None, str] = Agent( + model=resolved.model, + instructions=("Answer briefly. Use reasoning if available, then give the final answer."), + output_type=str, + ) + + counts: Counter[str] = Counter() + thinking_chunks: list[str] = [] + + async with agent.iter( + prompt, + model_settings=resolved.capture_model_settings, + ) as run: + async for node in run: + if Agent.is_model_request_node(node) or Agent.is_call_tools_node(node): + async with node.stream(run.ctx) as stream: + async for event in stream: + counts[type(event).__name__] += 1 + _record_part_shape(event, counts) + _record_thinking_event(event, thinking_chunks, counts) + + print(f"event_counts={dict(counts)}") + print(f"thinking_chunk_count={len(thinking_chunks)}") + if thinking_chunks: + preview = "".join(thinking_chunks)[:1000] + print(f"thinking_preview={preview!r}") + else: + print("thinking_preview=None") + + +def _record_part_shape(event: Any, counts: Counter[str]) -> None: + if isinstance(event, PartStartEvent): + counts[f"PartStartEvent:{type(event.part).__name__}"] += 1 + elif isinstance(event, PartDeltaEvent): + counts[f"PartDeltaEvent:{type(event.delta).__name__}"] += 1 + if isinstance(event.delta, TextPartDelta) and event.delta.content_delta: + counts["text_delta_chars"] += len(event.delta.content_delta) + elif isinstance(event, PartEndEvent): + counts[f"PartEndEvent:{type(event.part).__name__}"] += 1 + + +def _record_thinking_event( + event: Any, + thinking_chunks: list[str], + counts: Counter[str], +) -> None: + if isinstance(event, PartStartEvent) and isinstance(event.part, ThinkingPart): + counts["ThinkingPart:start"] += 1 + if content := _thinking_content(event.part): + thinking_chunks.append(content) + elif isinstance(event, PartDeltaEvent) and isinstance( + event.delta, + ThinkingPartDelta, + ): + counts["ThinkingPartDelta"] += 1 + if event.delta.content_delta: + thinking_chunks.append(event.delta.content_delta) + elif isinstance(event, PartEndEvent) and isinstance(event.part, ThinkingPart): + counts["ThinkingPart:end"] += 1 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--model", + default="openrouter:anthropic/claude-opus-4.7", + help="Model target to resolve and optionally call.", + ) + parser.add_argument( + "--prompt", + default="In one sentence, explain why task decomposition helps research agents.", + ) + args = parser.parse_args() + asyncio.run(_run_stream(args.model, args.prompt)) + + +def _required_api_key_name(model: str) -> str | None: + if model.startswith(("openrouter:", "openai-responses:")): + return "OPENROUTER_API_KEY" + if model.startswith("anthropic:"): + return "ANTHROPIC_API_KEY" + return None + + +if __name__ == "__main__": + main() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c9689728..4d18d38b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -27,7 +27,7 @@ import pytest import pytest_asyncio from ergon_core.core.persistence.shared.db import ensure_db -from ergon_core.core.runtime.inngest_client import inngest_client +from ergon_core.core.runtime.inngest.client import inngest_client from ergon_core.core.settings import settings from inngest._internal import net as inngest_net diff --git a/tests/integration/minif2f/test_sandbox_manager.py b/tests/integration/minif2f/test_sandbox_manager.py index 9df69456..61bf878f 100644 --- a/tests/integration/minif2f/test_sandbox_manager.py +++ b/tests/integration/minif2f/test_sandbox_manager.py @@ -10,7 +10,7 @@ import pytest from ergon_builtins.benchmarks.minif2f.sandbox.utils import resolve_template from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager # --------------------------------------------------------------------------- # Reset the singleton between tests — BaseSandboxManager stores _instance and @@ -104,12 +104,12 @@ async def test_create_threads_template_kwarg_to_e2b_sdk( fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) # settings.e2b_api_key must be truthy for create() to proceed. monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -157,11 +157,11 @@ async def _run(cmd: str, **_kwargs: object) -> MagicMock: fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -188,11 +188,11 @@ async def test_base_class_omits_template_when_unset(monkeypatch: pytest.MonkeyPa ) fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) diff --git a/tests/integration/minif2f/test_verification_integration.py b/tests/integration/minif2f/test_verification_integration.py index 356ec8a0..c5967af2 100644 --- a/tests/integration/minif2f/test_verification_integration.py +++ b/tests/integration/minif2f/test_verification_integration.py @@ -23,7 +23,7 @@ from ergon_core.api.evaluation_context import EvaluationContext from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager from ergon_core.core.runtime.evaluation.criterion_runtime import ( DefaultCriterionRuntime, ) diff --git a/tests/integration/researchrubrics/test_sandbox_manager.py b/tests/integration/researchrubrics/test_sandbox_manager.py index 7351eb28..a80f1ba6 100644 --- a/tests/integration/researchrubrics/test_sandbox_manager.py +++ b/tests/integration/researchrubrics/test_sandbox_manager.py @@ -16,8 +16,8 @@ from uuid import uuid4 import pytest -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager -from ergon_core.core.providers.sandbox.research_rubrics_manager import ( +from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_builtins.benchmarks.researchrubrics.sandbox_manager import ( ResearchRubricsSandboxManager, ) @@ -68,15 +68,15 @@ async def test_create_injects_exa_api_key_into_sandbox_envs( fake_sandbox = _make_fake_sandbox() fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-e2b-key", ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.exa_api_key", + "ergon_core.core.sandbox.manager.settings.exa_api_key", "test-exa-key-xyz", ) @@ -107,15 +107,15 @@ async def test_create_fails_fast_when_required_key_missing_from_settings( fake_sandbox = _make_fake_sandbox() fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-e2b-key", ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.exa_api_key", + "ergon_core.core.sandbox.manager.settings.exa_api_key", "", ) diff --git a/tests/integration/sandbox/test_required_env_keys.py b/tests/integration/sandbox/test_required_env_keys.py index 616733d0..b7533b58 100644 --- a/tests/integration/sandbox/test_required_env_keys.py +++ b/tests/integration/sandbox/test_required_env_keys.py @@ -26,13 +26,13 @@ import pytest from ergon_builtins.benchmarks.gdpeval.sandbox import GDPEvalSandboxManager from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager +from ergon_builtins.benchmarks.researchrubrics.sandbox_manager import ( + ResearchRubricsSandboxManager, +) from ergon_builtins.benchmarks.swebench_verified.sandbox_manager import ( SWEBenchSandboxManager, ) -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager -from ergon_core.core.providers.sandbox.research_rubrics_manager import ( - ResearchRubricsSandboxManager, -) +from ergon_core.core.sandbox.manager import BaseSandboxManager # Every concrete ``BaseSandboxManager`` subclass ergon ships. Add new # managers here so the env-injection contract is enforced for them too. @@ -84,11 +84,11 @@ def _install_async_sandbox_and_e2b_key(monkeypatch: pytest.MonkeyPatch) -> Async fake_sandbox = _make_fake_sandbox() fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-e2b-key", ) return fake_create @@ -128,7 +128,7 @@ async def test_required_env_keys_round_trip_into_sandbox( dummy = f"dummy-{key}-{idx}" expected_envs[key] = dummy monkeypatch.setattr( - f"ergon_core.core.providers.sandbox.manager.settings.{key.lower()}", + f"ergon_core.core.sandbox.manager.settings.{key.lower()}", dummy, ) diff --git a/tests/integration/swebench_verified/test_sandbox_manager.py b/tests/integration/swebench_verified/test_sandbox_manager.py index 40d19255..6318fc4e 100644 --- a/tests/integration/swebench_verified/test_sandbox_manager.py +++ b/tests/integration/swebench_verified/test_sandbox_manager.py @@ -11,7 +11,7 @@ from ergon_builtins.benchmarks.swebench_verified.sandbox_manager import ( SWEBenchSandboxManager, ) -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager # --------------------------------------------------------------------------- # Reset the singleton between tests — BaseSandboxManager stores _instance and @@ -113,12 +113,12 @@ async def test_create_threads_template_kwarg_to_e2b_sdk( fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) # settings.e2b_api_key must be truthy for create() to proceed. monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -178,11 +178,11 @@ async def _run(cmd: str, **_kwargs: object) -> MagicMock: fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) diff --git a/tests/unit/architecture/test_model_field_descriptions.py b/tests/unit/architecture/test_model_field_descriptions.py new file mode 100644 index 00000000..1d7e4e35 --- /dev/null +++ b/tests/unit/architecture/test_model_field_descriptions.py @@ -0,0 +1,82 @@ +"""Guards for model field docs that must survive schema export.""" + +from ergon_core.core.dashboard.event_contracts import DashboardContextEventEvent +from ergon_core.core.persistence.context.event_payloads import ( + AssistantTextPayload, + ThinkingPayload, + ToolCallPayload, + ToolResultPayload, + UserMessagePayload, +) +from ergon_core.core.persistence.context.models import RunContextEvent +from ergon_core.core.persistence.graph.models import ( + RunGraphAnnotation, + RunGraphMutation, + RunGraphNode, +) +from ergon_core.core.persistence.telemetry.models import RunResource +from ergon_core.core.runtime.services.graph_dto import ( + GraphAnnotationDto, + GraphEdgeDto, + GraphMutationDto, + GraphNodeDto, +) +from ergon_builtins.benchmarks.swebench_verified.task_schemas import ( + SWEBenchInstance, + SWEBenchTaskPayload, +) +from pydantic import BaseModel + + +def _description(model: type[BaseModel], field_name: str) -> str | None: + return model.model_fields[field_name].description + + +def test_context_event_payload_field_docs_are_schema_metadata() -> None: + assert _description(UserMessagePayload, "from_worker_key") + assert _description(AssistantTextPayload, "turn_id") + assert _description(AssistantTextPayload, "turn_token_ids") + assert _description(AssistantTextPayload, "turn_logprobs") + assert _description(ToolCallPayload, "turn_id") + assert _description(ToolCallPayload, "turn_token_ids") + assert _description(ToolCallPayload, "turn_logprobs") + assert _description(ToolResultPayload, "tool_call_id") + assert _description(ToolResultPayload, "result") + assert _description(ThinkingPayload, "turn_id") + assert _description(ThinkingPayload, "turn_token_ids") + assert _description(ThinkingPayload, "turn_logprobs") + + +def test_dashboard_context_event_field_docs_are_schema_metadata() -> None: + assert _description(DashboardContextEventEvent, "id") + assert _description(DashboardContextEventEvent, "task_node_id") + assert _description(DashboardContextEventEvent, "payload") + + +def test_graph_dto_field_docs_are_schema_metadata() -> None: + assert _description(GraphNodeDto, "status") + assert _description(GraphEdgeDto, "status") + assert _description(GraphAnnotationDto, "id") + assert _description(GraphAnnotationDto, "target_id") + assert _description(GraphMutationDto, "id") + assert _description(GraphMutationDto, "target_id") + + +def test_sqlmodel_field_docs_are_schema_metadata() -> None: + assert _description(RunGraphNode, "instance_key") + assert _description(RunGraphNode, "task_slug") + assert _description(RunGraphNode, "status") + assert _description(RunGraphNode, "assigned_worker_slug") + assert _description(RunGraphNode, "parent_node_id") + assert _description(RunGraphNode, "level") + assert _description(RunContextEvent, "event_type") + assert _description(RunContextEvent, "payload") + assert _description(RunGraphAnnotation, "target_type") + assert _description(RunGraphMutation, "mutation_type") + assert _description(RunGraphMutation, "target_type") + assert _description(RunResource, "kind") + + +def test_builtin_task_schema_field_docs_are_schema_metadata() -> None: + assert _description(SWEBenchInstance, "hints_text") + assert _description(SWEBenchTaskPayload, "hints_text") diff --git a/tests/unit/benchmarks/test_swebench_sandbox_manager.py b/tests/unit/benchmarks/test_swebench_sandbox_manager.py index 2462cd35..9900d9a7 100644 --- a/tests/unit/benchmarks/test_swebench_sandbox_manager.py +++ b/tests/unit/benchmarks/test_swebench_sandbox_manager.py @@ -55,7 +55,7 @@ async def test_install_runs_setup_and_install_scripts(monkeypatch: pytest.Monkey @pytest.mark.asyncio async def test_install_raises_when_payload_missing(monkeypatch: pytest.MonkeyPatch) -> None: from ergon_core.core.persistence import queries as q_mod - from ergon_core.core.providers.sandbox.errors import SandboxSetupError + from ergon_core.core.sandbox.errors import SandboxSetupError monkeypatch.setattr( q_mod.queries.task_executions, @@ -90,7 +90,7 @@ async def test_install_raises_on_nonzero_exit( """ from ergon_builtins.benchmarks.swebench_verified import sandbox_manager as sm from ergon_core.core.persistence import queries as q_mod - from ergon_core.core.providers.sandbox.errors import SandboxSetupError + from ergon_core.core.sandbox.errors import SandboxSetupError monkeypatch.setattr( q_mod.queries.task_executions, diff --git a/tests/unit/builtins/test_logfire_pydantic_ai.py b/tests/unit/builtins/test_logfire_pydantic_ai.py new file mode 100644 index 00000000..ec8206b2 --- /dev/null +++ b/tests/unit/builtins/test_logfire_pydantic_ai.py @@ -0,0 +1,53 @@ +import importlib + + +def test_logfire_pydantic_ai_instrumentation_is_disabled_by_default(monkeypatch) -> None: + module = importlib.import_module("ergon_builtins.observability.pydantic_ai_logfire") + module._reset_for_tests() + monkeypatch.delenv("ERGON_LOGFIRE_PYDANTIC_AI", raising=False) + + assert module.configure_pydantic_ai_logfire(logfire_module=_FailingLogfire()) is False + + +def test_logfire_pydantic_ai_instrumentation_configures_once(monkeypatch) -> None: + module = importlib.import_module("ergon_builtins.observability.pydantic_ai_logfire") + module._reset_for_tests() + monkeypatch.setenv("ERGON_LOGFIRE_PYDANTIC_AI", "1") + monkeypatch.setenv("ERGON_LOGFIRE_SERVICE_NAME", "ergon-test") + monkeypatch.setenv("ERGON_LOGFIRE_ENVIRONMENT", "unit") + monkeypatch.setenv("ERGON_LOGFIRE_CONFIG_DIR", "/tmp/logfire-config") + fake = _FakeLogfire() + + assert module.configure_pydantic_ai_logfire(logfire_module=fake) is True + assert module.configure_pydantic_ai_logfire(logfire_module=fake) is True + + assert fake.configure_calls == [ + { + "send_to_logfire": "if-token-present", + "service_name": "ergon-test", + "environment": "unit", + "config_dir": "/tmp/logfire-config", + "console": False, + } + ] + assert fake.instrument_calls == [{"include_content": True}] + + +class _FailingLogfire: + def configure(self, **kwargs): + raise AssertionError("disabled instrumentation should not configure Logfire") + + def instrument_pydantic_ai(self, **kwargs): + raise AssertionError("disabled instrumentation should not instrument pydantic-ai") + + +class _FakeLogfire: + def __init__(self) -> None: + self.configure_calls = [] + self.instrument_calls = [] + + def configure(self, **kwargs): + self.configure_calls.append(kwargs) + + def instrument_pydantic_ai(self, **kwargs): + self.instrument_calls.append(kwargs) diff --git a/tests/unit/builtins/test_tool_budget.py b/tests/unit/builtins/test_tool_budget.py new file mode 100644 index 00000000..29a60e8d --- /dev/null +++ b/tests/unit/builtins/test_tool_budget.py @@ -0,0 +1,51 @@ +from ergon_builtins.workers.baselines.tool_budget import ( + AgentToolBudgetDeps, + AgentToolBudgetExhaustedResult, + AgentToolBudgetState, +) + + +def test_tool_budget_exhausts_workflow_calls_with_structured_result() -> None: + state = AgentToolBudgetState( + max_workflow_tool_calls=1, + max_other_tool_calls=2, + ) + + first = state.increment("workflow", "workflow") + second = state.increment("workflow", "workflow") + exhausted = state.exhausted_result("workflow tool budget reached") + + assert first == 1 + assert second == 2 + assert second > state.max_workflow_tool_calls + assert isinstance(exhausted, AgentToolBudgetExhaustedResult) + assert exhausted.status == "TOOL_BUDGET_EXHAUSTED" + assert exhausted.reason == "workflow tool budget reached" + assert exhausted.budget_state["workflow_tool_calls"] == 2 + + +def test_tool_budget_allows_finalization_after_other_exhaustion() -> None: + state = AgentToolBudgetState( + max_workflow_tool_calls=1, + max_other_tool_calls=1, + ) + + assert state.increment("exa_search", "other") == 1 + assert state.increment("list_child_resources", "other") == 2 + finalization_count = state.increment("write_report_draft", "finalization") + + assert state.other_tool_calls > state.max_other_tool_calls + assert finalization_count == 1 + assert state.finalization_tool_calls == 1 + + +def test_tool_budget_deps_wraps_mutable_state() -> None: + state = AgentToolBudgetState( + max_workflow_tool_calls=1, + max_other_tool_calls=1, + ) + deps = AgentToolBudgetDeps(tool_budget=state) + + deps.tool_budget.increment("exa_search", "other") + + assert deps.tool_budget.other_tool_calls == 1 diff --git a/tests/unit/runtime/test_criterion_runtime_reconnect.py b/tests/unit/runtime/test_criterion_runtime_reconnect.py index 50ef8bab..8d068200 100644 --- a/tests/unit/runtime/test_criterion_runtime_reconnect.py +++ b/tests/unit/runtime/test_criterion_runtime_reconnect.py @@ -12,7 +12,7 @@ from uuid import uuid4 import pytest -from ergon_core.core.providers.sandbox.errors import SandboxExpiredError +from ergon_core.core.sandbox.errors import SandboxExpiredError from ergon_core.core.runtime.evaluation.criterion_runtime import ( CriterionRuntimeOptions, DefaultCriterionRuntime, diff --git a/tests/unit/runtime/test_failed_task_sandbox_cleanup.py b/tests/unit/runtime/test_failed_task_sandbox_cleanup.py index e8e52b04..6b226102 100644 --- a/tests/unit/runtime/test_failed_task_sandbox_cleanup.py +++ b/tests/unit/runtime/test_failed_task_sandbox_cleanup.py @@ -1,7 +1,7 @@ from unittest.mock import AsyncMock, patch import pytest -from ergon_core.core.providers.sandbox.lifecycle import ( +from ergon_core.core.sandbox.lifecycle import ( SandboxTerminationReason, SandboxTerminationResult, ) diff --git a/tests/unit/runtime/test_failure_error_json.py b/tests/unit/runtime/test_failure_error_json.py index c4807411..2b4aea1c 100644 --- a/tests/unit/runtime/test_failure_error_json.py +++ b/tests/unit/runtime/test_failure_error_json.py @@ -6,47 +6,6 @@ from ergon_core.core.runtime.services.orchestration_dto import FailTaskExecutionCommand -def test_build_error_json_includes_stack_without_inferred_triage() -> None: - from ergon_core.core.runtime.errors.error_payload import ( - RuntimeErrorPayload, - build_error_json, - ) - - try: - raise RuntimeError( - "Invalid response from OpenAI chat completions endpoint: " - "choices.0.finish_reason input_value=None" - ) - except RuntimeError as exc: - payload = build_error_json(exc, phase="worker_execute") - - assert payload["message"].startswith("Invalid response from OpenAI") - assert payload["exception_type"] == "RuntimeError" - assert payload["phase"] == "worker_execute" - assert "Traceback" in payload["stack"] - assert "finish_reason" in payload["stack"] - assert "category" not in payload - assert "retryable" not in payload - assert RuntimeErrorPayload.model_validate(payload).message == payload["message"] - - -def test_worker_exception_result_carries_structured_error_json() -> None: - from ergon_core.core.runtime.inngest.worker_execute import ( - _worker_execute_result_from_exception, - ) - - try: - raise RuntimeError("provider timeout") - except RuntimeError as exc: - result = _worker_execute_result_from_exception(exc) - - assert result.success is False - assert result.error == "provider timeout" - assert result.error_json is not None - assert result.error_json["phase"] == "worker_execute" - assert result.error_json["exception_type"] == "RuntimeError" - - @pytest.mark.asyncio async def test_finalize_failure_preserves_structured_error_json(monkeypatch) -> None: from ergon_core.core.runtime.services import task_execution_service as module diff --git a/tests/unit/runtime/test_inngest_criterion_executor.py b/tests/unit/runtime/test_inngest_criterion_executor.py new file mode 100644 index 00000000..070582c7 --- /dev/null +++ b/tests/unit/runtime/test_inngest_criterion_executor.py @@ -0,0 +1,86 @@ +"""Contracts for Inngest criterion executor runtime wiring.""" + +from uuid import uuid4 + +import pytest +from ergon_core.api.criterion import Criterion +from ergon_core.api.evaluation_context import EvaluationContext +from ergon_core.api.results import CriterionResult +from ergon_core.api.task_types import BenchmarkTask +from ergon_core.core.runtime.evaluation.evaluation_schemas import ( + CriterionSpec, + TaskEvaluationContext, +) +from ergon_core.core.runtime.evaluation.inngest_executor import InngestCriterionExecutor + + +class _Step: + async def run(self, _name, fn, *, output_type): + return await fn() + + +class _Group: + async def parallel(self, fns): + return [await fn() for fn in fns] + + +class _Ctx: + step = _Step() + group = _Group() + + +class _Criterion(Criterion): + type_slug = "test-criterion" + + def __init__(self) -> None: + super().__init__(name="criterion") + self.runtime_task_scope = None + + async def evaluate(self, context: EvaluationContext) -> CriterionResult: + self.runtime_task_scope = context.runtime.task_scope + return CriterionResult(name=self.name, score=1.0, passed=True) + + +@pytest.mark.asyncio +async def test_executor_scopes_criterion_runtime_to_task_execution(monkeypatch) -> None: + execution_id = uuid4() + definition_task_id = uuid4() + captured_options = [] + + class FakeRuntime: + def __init__(self, *, context, sandbox_manager, options) -> None: + captured_options.append(options) + self.task_scope = options.task_id + + monkeypatch.setattr( + "ergon_core.core.runtime.evaluation.inngest_executor.DefaultCriterionRuntime", + FakeRuntime, + ) + + criterion = _Criterion() + executor = InngestCriterionExecutor( + _Ctx(), + task_id=definition_task_id, + execution_id=execution_id, + evaluator_id=uuid4(), + sandbox_manager=object(), + ) + + await executor.execute_all( + TaskEvaluationContext( + run_id=uuid4(), + task_input="input", + agent_reasoning="output", + ), + BenchmarkTask( + task_slug="task", + instance_key="default", + description="input", + evaluator_binding_keys=("default",), + ), + "benchmark", + [CriterionSpec(criterion=criterion)], + ) + + assert captured_options[0].task_id == execution_id + assert criterion.runtime_task_scope == execution_id diff --git a/tests/unit/runtime/test_inngest_package_layout.py b/tests/unit/runtime/test_inngest_package_layout.py new file mode 100644 index 00000000..6d2e9f88 --- /dev/null +++ b/tests/unit/runtime/test_inngest_package_layout.py @@ -0,0 +1,10 @@ +import importlib +import importlib.util + + +def test_inngest_infrastructure_lives_in_inngest_package() -> None: + client_module = importlib.import_module("ergon_core.core.runtime.inngest.client") + registry_spec = importlib.util.find_spec("ergon_core.core.runtime.inngest.registry") + + assert client_module.inngest_client is not None + assert registry_spec is not None diff --git a/tests/unit/runtime/test_worker_execute_output_failure.py b/tests/unit/runtime/test_worker_execute_output_failure.py deleted file mode 100644 index f421a542..00000000 --- a/tests/unit/runtime/test_worker_execute_output_failure.py +++ /dev/null @@ -1,12 +0,0 @@ -from ergon_core.api.results import WorkerOutput -from ergon_core.core.runtime.inngest.worker_execute import _worker_execute_result_from_output - - -def test_worker_execute_result_preserves_worker_output_failure() -> None: - result = _worker_execute_result_from_output( - WorkerOutput(output="probe failed", success=False), - ) - - assert result.success is False - assert result.final_assistant_message == "probe failed" - assert result.error == "probe failed" diff --git a/tests/unit/sandbox/test_ensure_sandbox_idempotence.py b/tests/unit/sandbox/test_ensure_sandbox_idempotence.py index 0ff301d2..2f17bd82 100644 --- a/tests/unit/sandbox/test_ensure_sandbox_idempotence.py +++ b/tests/unit/sandbox/test_ensure_sandbox_idempotence.py @@ -12,7 +12,7 @@ from uuid import UUID, uuid4 import pytest -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.manager import BaseSandboxManager class _ProbeManager(BaseSandboxManager): @@ -83,11 +83,11 @@ async def test_install_dependencies_runs_exactly_once_on_repeated_create( # `AsyncSandbox` binding in `manager.py` to return our fake sandbox. fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) diff --git a/tests/unit/sandbox/test_sandbox_lifecycle_service.py b/tests/unit/sandbox/test_sandbox_lifecycle_service.py index c1403a34..fd44f1af 100644 --- a/tests/unit/sandbox/test_sandbox_lifecycle_service.py +++ b/tests/unit/sandbox/test_sandbox_lifecycle_service.py @@ -1,7 +1,7 @@ from unittest.mock import AsyncMock, patch import pytest -from ergon_core.core.providers.sandbox.lifecycle import ( +from ergon_core.core.sandbox.lifecycle import ( SandboxTerminationReason, terminate_sandbox_by_id, ) @@ -10,7 +10,7 @@ @pytest.mark.asyncio async def test_terminate_sandbox_by_id_dispatches_real_ids() -> None: with patch( - "ergon_core.core.providers.sandbox.manager.BaseSandboxManager.terminate_by_sandbox_id", + "ergon_core.core.sandbox.manager.BaseSandboxManager.terminate_by_sandbox_id", new=AsyncMock(return_value=True), ) as terminate: result = await terminate_sandbox_by_id("sbx-live-123") diff --git a/tests/unit/sandbox/test_sandbox_reconnect.py b/tests/unit/sandbox/test_sandbox_reconnect.py index bde1e87c..c819fcf8 100644 --- a/tests/unit/sandbox/test_sandbox_reconnect.py +++ b/tests/unit/sandbox/test_sandbox_reconnect.py @@ -8,8 +8,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from ergon_core.core.providers.sandbox.errors import SandboxExpiredError -from ergon_core.core.providers.sandbox.manager import BaseSandboxManager +from ergon_core.core.sandbox.errors import SandboxExpiredError +from ergon_core.core.sandbox.manager import BaseSandboxManager class _MinimalManager(BaseSandboxManager): @@ -51,11 +51,11 @@ async def test_reconnect_returns_sandbox_on_success( fake_sandbox = MagicMock() fake_connect = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -82,11 +82,11 @@ async def test_reconnect_does_not_register_in_sandboxes_dict( fake_sandbox = MagicMock() fake_connect = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -112,11 +112,11 @@ async def test_reconnect_idempotent_returns_equivalent_handles( fake_sandbox_b = MagicMock() fake_connect = AsyncMock(side_effect=[fake_sandbox_a, fake_sandbox_b]) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -134,7 +134,7 @@ async def test_reconnect_raises_sandbox_expired_on_not_found_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """SandboxNotFoundException → SandboxExpiredError with sandbox_id preserved.""" - import ergon_core.core.providers.sandbox.manager as mgr_mod + import ergon_core.core.sandbox.manager as mgr_mod class _FakeSandboxNotFound(Exception): pass @@ -165,7 +165,7 @@ async def test_reconnect_raises_sandbox_expired_on_timeout_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """TimeoutException → SandboxExpiredError.""" - import ergon_core.core.providers.sandbox.manager as mgr_mod + import ergon_core.core.sandbox.manager as mgr_mod class _FakeTimeout(Exception): pass @@ -198,11 +198,11 @@ async def test_reconnect_classifies_by_message_when_sdk_raises_generic_error( side_effect=Exception("HTTP 404: sandbox not found"), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -225,11 +225,11 @@ async def test_reconnect_reraises_unrelated_errors_unchanged( """ fake_connect = AsyncMock(side_effect=ConnectionError("TLS handshake failed")) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.AsyncSandbox", + "ergon_core.core.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.providers.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.sandbox.manager.settings.e2b_api_key", "test-key", ) diff --git a/tests/unit/smoke_base/test_leaf_sends_completion_message.py b/tests/unit/smoke_base/test_leaf_sends_completion_message.py index 3ba972ab..d51ce270 100644 --- a/tests/unit/smoke_base/test_leaf_sends_completion_message.py +++ b/tests/unit/smoke_base/test_leaf_sends_completion_message.py @@ -11,7 +11,7 @@ import pytest from ergon_core.api import BenchmarkTask from ergon_core.core.persistence.shared.types import AssignedWorkerSlug -from ergon_core.core.providers.sandbox.manager import AsyncSandbox +from ergon_core.core.sandbox.manager import AsyncSandbox from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult diff --git a/tests/unit/smoke_base/test_smoke_sandbox_manager.py b/tests/unit/smoke_base/test_smoke_sandbox_manager.py index 35d22663..709801e3 100644 --- a/tests/unit/smoke_base/test_smoke_sandbox_manager.py +++ b/tests/unit/smoke_base/test_smoke_sandbox_manager.py @@ -2,7 +2,7 @@ from uuid import UUID, uuid4 import pytest -from ergon_core.core.providers.sandbox.event_sink import SandboxEventSink +from ergon_core.core.sandbox.event_sink import SandboxEventSink class _RecordingSink(SandboxEventSink): @@ -82,8 +82,8 @@ async def test_smoke_sandbox_health_command_matches_swebench_probe() -> None: @pytest.mark.asyncio async def test_static_teardown_closes_registered_smoke_sandbox() -> None: - from ergon_core.core.providers.sandbox.event_sink import NoopSandboxEventSink - from ergon_core.core.providers.sandbox.manager import BaseSandboxManager + from ergon_core.core.sandbox.event_sink import NoopSandboxEventSink + from ergon_core.core.sandbox.manager import BaseSandboxManager from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager sink = _RecordingSink() diff --git a/tests/unit/state/test_criterion_runtime_di.py b/tests/unit/state/test_criterion_runtime_di.py index 46b47b72..0e9b755b 100644 --- a/tests/unit/state/test_criterion_runtime_di.py +++ b/tests/unit/state/test_criterion_runtime_di.py @@ -12,7 +12,7 @@ import pytest from ergon_core.core.runtime.evaluation.protocols import CriterionRuntime from ergon_core.core.runtime.resources import RunResourceView -from ergon_core.core.providers.sandbox.event_sink import ( +from ergon_core.core.sandbox.event_sink import ( DashboardEmitterSandboxEventSink, NoopSandboxEventSink, ) diff --git a/tests/unit/test_dashboard_emitter_wiring.py b/tests/unit/test_dashboard_emitter_wiring.py index fc8e1db7..3f080195 100644 --- a/tests/unit/test_dashboard_emitter_wiring.py +++ b/tests/unit/test_dashboard_emitter_wiring.py @@ -32,7 +32,7 @@ { "ergon_core/ergon_core/core/dashboard/emitter.py", "ergon_core/ergon_core/core/dashboard/event_contracts.py", - "ergon_core/ergon_core/core/providers/sandbox/event_sink.py", + "ergon_core/ergon_core/core/sandbox/event_sink.py", } ) From cd21b0cc340e0ae2e2fbaff0c8614dc48cd2b1c6 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:45:51 +0100 Subject: [PATCH 37/66] docs: trim cleanup plan trailing whitespace Made-with: Cursor --- docs/superpowers/plans/2026-04-28-agent-tool-budget-harness.md | 1 - .../plans/2026-04-28-evaluation-resource-context-and-scoring.md | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/superpowers/plans/2026-04-28-agent-tool-budget-harness.md b/docs/superpowers/plans/2026-04-28-agent-tool-budget-harness.md index 7af71aa7..c611f731 100644 --- a/docs/superpowers/plans/2026-04-28-agent-tool-budget-harness.md +++ b/docs/superpowers/plans/2026-04-28-agent-tool-budget-harness.md @@ -808,4 +808,3 @@ Expected improvement: - This still supports better prompt steering, but prompt steering is advisory. The two counters are enforcement. - We should not add broad unit tests for every tool. Existing workflow tests, import smoke checks, lint, and the one-sample real rollout are enough for this change. - Do not commit unless explicitly asked. - diff --git a/docs/superpowers/plans/2026-04-28-evaluation-resource-context-and-scoring.md b/docs/superpowers/plans/2026-04-28-evaluation-resource-context-and-scoring.md index f714978d..59306462 100644 --- a/docs/superpowers/plans/2026-04-28-evaluation-resource-context-and-scoring.md +++ b/docs/superpowers/plans/2026-04-28-evaluation-resource-context-and-scoring.md @@ -906,4 +906,3 @@ Expected rollout properties: - Do not include full agent conversation in ResearchRubrics judge prompts by default. - Do not introduce a new persisted table for evidence bundles. - Do not preserve compatibility with double-normalized summary scores; new runs should use the normalized score invariant. - From f629cbe8a2e31e39353414d6ebe70ed50b35ec72 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:58:46 +0100 Subject: [PATCH 38/66] fix: trim schema trailing whitespace Made-with: Cursor --- ergon_core/ergon_core/core/api/schemas.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ergon_core/ergon_core/core/api/schemas.py b/ergon_core/ergon_core/core/api/schemas.py index 8ae9fd87..0d532aab 100644 --- a/ergon_core/ergon_core/core/api/schemas.py +++ b/ergon_core/ergon_core/core/api/schemas.py @@ -248,4 +248,3 @@ class TrainingMetricDto(CamelModel): completion_mean_length: float | None = None step_time_s: float | None = None - From 4875c943ca0ac7e2528b9047fb21c848352be575 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:00:47 +0100 Subject: [PATCH 39/66] fix: align field docs guard with context stream schema Made-with: Cursor --- ergon_core/ergon_core/core/generation.py | 102 +++++++++++++----- .../test_model_field_descriptions.py | 42 ++++---- 2 files changed, 94 insertions(+), 50 deletions(-) diff --git a/ergon_core/ergon_core/core/generation.py b/ergon_core/ergon_core/core/generation.py index 68e3a94a..0178d686 100644 --- a/ergon_core/ergon_core/core/generation.py +++ b/ergon_core/ergon_core/core/generation.py @@ -17,50 +17,76 @@ class TokenLogprob(BaseModel): model_config = {"frozen": True} - token: str - logprob: float - top_logprobs: list[JsonObject] = Field(default_factory=list) + token: str = Field(description="Generated token text.") + logprob: float = Field(description="Natural-log probability assigned to the token.") + top_logprobs: list[JsonObject] = Field( + default_factory=list, + description="Optional model-provider alternatives and probabilities for this position.", + ) class SystemPromptPart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["system_prompt"] = "system_prompt" - content: str + part_kind: Literal["system_prompt"] = Field( + default="system_prompt", + description="Discriminator identifying this context part as a system prompt.", + ) + content: str = Field(description="System instructions supplied to the worker.") class UserMessagePart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["user_message"] = "user_message" - content: str + part_kind: Literal["user_message"] = Field( + default="user_message", + description="Discriminator identifying this context part as a user message.", + ) + content: str = Field(description="User or upstream task message content.") class AssistantTextPart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["assistant_text"] = "assistant_text" - content: str + part_kind: Literal["assistant_text"] = Field( + default="assistant_text", + description="Discriminator identifying this context part as assistant text.", + ) + content: str = Field(description="Assistant response text emitted by the worker.") class ToolCallPart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["tool_call"] = "tool_call" - tool_name: str - tool_call_id: str - args: dict[str, Any] # slopcop: ignore[no-typing-any] + part_kind: Literal["tool_call"] = Field( + default="tool_call", + description="Discriminator identifying this context part as a tool call.", + ) + tool_name: str = Field(description="Name of the tool requested by the worker.") + tool_call_id: str = Field(description="Provider-stable identifier for this tool call.") + args: dict[str, Any] = Field( # slopcop: ignore[no-typing-any] + description="JSON-like tool input arguments.", + ) class ToolResultPart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["tool_result"] = "tool_result" - tool_call_id: str - tool_name: str - content: str - is_error: bool = False + part_kind: Literal["tool_result"] = Field( + default="tool_result", + description="Discriminator identifying this context part as a tool result.", + ) + tool_call_id: str = Field(description="Identifier of the tool call this result answers.") + tool_name: str = Field(description="Name of the tool that produced this result.") + content: str = Field(description="Serialized tool result content.") + is_error: bool = Field( + default=False, + description="Whether the tool result represents an error response.", + ) class ThinkingPart(BaseModel): model_config = {"frozen": True} - part_kind: Literal["thinking"] = "thinking" - content: str + part_kind: Literal["thinking"] = Field( + default="thinking", + description="Discriminator identifying this context part as private thinking.", + ) + content: str = Field(description="Reasoning or thinking text emitted by the model.") ContextPart = Annotated[ @@ -82,20 +108,38 @@ class ContextPartChunk(BaseModel): model_config = {"frozen": True} - part: ContextPart - token_ids: list[int] | None = None - logprobs: list[TokenLogprob] | None = None + part: ContextPart = Field(description="Typed context stream payload.") + token_ids: list[int] | None = Field( + default=None, + description="Token IDs associated with this context part when provided by the backend.", + ) + logprobs: list[TokenLogprob] | None = Field( + default=None, + description="Per-token log probabilities associated with this context part.", + ) class ContextPartChunkLog(ContextPartChunk): """Core-enriched context stream item suitable for API/dashboard projection.""" - sequence: int - worker_binding_key: str - turn_id: str | None = None - started_at: datetime | None = None - completed_at: datetime | None = None - policy_version: str | None = None + sequence: int = Field(description="Monotonic sequence number within the execution stream.") + worker_binding_key: str = Field(description="Worker binding that emitted this context part.") + turn_id: str | None = Field( + default=None, + description="Stable generation turn identifier shared by related streamed parts.", + ) + started_at: datetime | None = Field( + default=None, + description="Timestamp when generation for this part started.", + ) + completed_at: datetime | None = Field( + default=None, + description="Timestamp when generation for this part completed.", + ) + policy_version: str | None = Field( + default=None, + description="Optional worker or policy version that produced the part.", + ) WorkerYield = ContextPartChunk diff --git a/tests/unit/architecture/test_model_field_descriptions.py b/tests/unit/architecture/test_model_field_descriptions.py index 1d7e4e35..887c47e2 100644 --- a/tests/unit/architecture/test_model_field_descriptions.py +++ b/tests/unit/architecture/test_model_field_descriptions.py @@ -1,12 +1,13 @@ """Guards for model field docs that must survive schema export.""" from ergon_core.core.dashboard.event_contracts import DashboardContextEventEvent -from ergon_core.core.persistence.context.event_payloads import ( - AssistantTextPayload, - ThinkingPayload, - ToolCallPayload, - ToolResultPayload, - UserMessagePayload, +from ergon_core.core.generation import ( + AssistantTextPart, + ContextPartChunkLog, + ThinkingPart, + ToolCallPart, + ToolResultPart, + UserMessagePart, ) from ergon_core.core.persistence.context.models import RunContextEvent from ergon_core.core.persistence.graph.models import ( @@ -18,7 +19,7 @@ from ergon_core.core.runtime.services.graph_dto import ( GraphAnnotationDto, GraphEdgeDto, - GraphMutationDto, + GraphMutationRecordDto, GraphNodeDto, ) from ergon_builtins.benchmarks.swebench_verified.task_schemas import ( @@ -33,18 +34,17 @@ def _description(model: type[BaseModel], field_name: str) -> str | None: def test_context_event_payload_field_docs_are_schema_metadata() -> None: - assert _description(UserMessagePayload, "from_worker_key") - assert _description(AssistantTextPayload, "turn_id") - assert _description(AssistantTextPayload, "turn_token_ids") - assert _description(AssistantTextPayload, "turn_logprobs") - assert _description(ToolCallPayload, "turn_id") - assert _description(ToolCallPayload, "turn_token_ids") - assert _description(ToolCallPayload, "turn_logprobs") - assert _description(ToolResultPayload, "tool_call_id") - assert _description(ToolResultPayload, "result") - assert _description(ThinkingPayload, "turn_id") - assert _description(ThinkingPayload, "turn_token_ids") - assert _description(ThinkingPayload, "turn_logprobs") + assert _description(UserMessagePart, "content") + assert _description(AssistantTextPart, "content") + assert _description(ToolCallPart, "tool_call_id") + assert _description(ToolCallPart, "args") + assert _description(ToolResultPart, "tool_call_id") + assert _description(ToolResultPart, "content") + assert _description(ThinkingPart, "content") + assert _description(ContextPartChunkLog, "worker_binding_key") + assert _description(ContextPartChunkLog, "turn_id") + assert _description(ContextPartChunkLog, "token_ids") + assert _description(ContextPartChunkLog, "logprobs") def test_dashboard_context_event_field_docs_are_schema_metadata() -> None: @@ -58,8 +58,8 @@ def test_graph_dto_field_docs_are_schema_metadata() -> None: assert _description(GraphEdgeDto, "status") assert _description(GraphAnnotationDto, "id") assert _description(GraphAnnotationDto, "target_id") - assert _description(GraphMutationDto, "id") - assert _description(GraphMutationDto, "target_id") + assert _description(GraphMutationRecordDto, "id") + assert _description(GraphMutationRecordDto, "target_id") def test_sqlmodel_field_docs_are_schema_metadata() -> None: From eae839c6f68d1e56ad6364612d92209656fe5a8b Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:19:32 +0100 Subject: [PATCH 40/66] fix: address CI integration type issues Add missing criterion result identifiers and tighten typing around model resolution, Logfire, OTel tracing, and workflow command dispatch so the combined branch is ready for main reconciliation. Made-with: Cursor --- .../minif2f/rules/proof_verification.py | 2 + .../researchrubrics/judge_criterion.py | 1 + .../benchmarks/swebench_verified/criterion.py | 3 + .../evaluators/criteria/code_check.py | 1 + .../evaluators/criteria/llm_judge.py | 1 + .../evaluators/criteria/sandbox_file_check.py | 7 +- .../ergon_builtins/models/resolution.py | 2 +- .../observability/pydantic_ai_logfire.py | 25 ++-- .../workers/baselines/react_worker.py | 31 +++-- .../workers/baselines/tool_budget.py | 2 - .../research_rubrics/researcher_worker.py | 10 +- .../workflow_cli_react_worker.py | 10 +- ergon_cli/ergon_cli/commands/workflow.py | 34 ++++-- ergon_cli/ergon_cli/composition/__init__.py | 6 +- ergon_core/ergon_core/core/api/schemas.py | 1 - .../ergon_core/core/dashboard/emitter.py | 22 ++-- .../persistence/graph/status_conventions.py | 1 + .../core/runtime/inngest/worker_execute.py | 4 +- .../ergon_core/core/runtime/tracing/otel.py | 17 ++- .../smoke_fixtures/criteria/timing.py | 1 + .../smoke_base/criterion_base.py | 2 + .../smoke_fixtures/smoke_base/recursive.py | 3 +- ...c9d0_normalize_evaluation_summary_nulls.py | 115 ++++++++++-------- scripts/spike_openrouter_reasoning.py | 3 +- .../dashboard/test_event_contract_types.py | 4 +- .../state/test_openrouter_model_resolution.py | 7 +- 26 files changed, 198 insertions(+), 117 deletions(-) diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py index 4afc0abc..8596ef26 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py @@ -72,6 +72,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: proof_data = await self._extract_proof(context) if proof_data is None: return CriterionResult( + slug=self.slug, name=self.name, score=0.0, passed=False, @@ -101,6 +102,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: ) return CriterionResult( + slug=self.slug, name=self.name, score=score, passed=outcome.verified, diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py index be8157bf..602b6d0c 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py @@ -68,6 +68,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: ] return CriterionResult( slug=self.slug, + name=self.name, score=self.score_spec.max_score if verdict.passed else 0.0, passed=verdict.passed, weight=self.weight, diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py index f6528ba1..273a8ddd 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py @@ -136,6 +136,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: patch_text = await _extract_patch_via_runtime(context) if not patch_text.strip(): return CriterionResult( + slug=self.slug, name=self.name, score=0.0, passed=False, @@ -214,6 +215,7 @@ async def _run_and_grade( entry = report.get(payload.instance_id, {}) if isinstance(report, dict) else {} resolved = bool(entry.get("resolved")) return CriterionResult( + slug=self.slug, name=self.name, score=1.0 if resolved else 0.0, passed=resolved, @@ -250,6 +252,7 @@ async def _write_and_apply( def _error_result(name: str, weight: float, kind: str, detail: str) -> CriterionResult: return CriterionResult( + slug=name, name=name, score=0.0, passed=False, diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py b/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py index 4842fa5f..61f71ffd 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py @@ -46,6 +46,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: score = self.score_spec.max_score if passed else 0.0 return CriterionResult( slug=self.slug, + name=self.name, score=score, passed=passed, weight=self.weight, diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py index 81969f85..046025b2 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py @@ -75,6 +75,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: score = self.score_spec.max_score if verdict.passed else 0.0 return CriterionResult( slug=self.slug, + name=self.name, score=score, passed=verdict.passed, weight=self.weight, diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py b/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py index afd5ae8e..659fe43c 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py @@ -21,14 +21,14 @@ def __init__( expected_path: str = MARKER_PATH, expected_content: str = MARKER_CONTENT, ) -> None: - self.name = name - self.weight = weight + super().__init__(name=name, weight=weight) self.expected_path = expected_path self.expected_content = expected_content async def evaluate(self, context: EvaluationContext) -> CriterionResult: if not context.sandbox_id: return CriterionResult( + slug=self.slug, name=self.name, score=0.0, passed=False, @@ -41,6 +41,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: from e2b_code_interpreter import AsyncSandbox except ImportError: return CriterionResult( + slug=self.slug, name=self.name, score=0.0, passed=False, @@ -57,6 +58,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: found = self.expected_content in content return CriterionResult( + slug=self.slug, name=self.name, score=1.0 if found else 0.0, passed=found, @@ -70,6 +72,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: ) except Exception as exc: # slopcop: ignore[no-broad-except] return CriterionResult( + slug=self.slug, name=self.name, score=0.0, passed=False, diff --git a/ergon_builtins/ergon_builtins/models/resolution.py b/ergon_builtins/ergon_builtins/models/resolution.py index db42b3eb..111d8623 100644 --- a/ergon_builtins/ergon_builtins/models/resolution.py +++ b/ergon_builtins/ergon_builtins/models/resolution.py @@ -73,7 +73,7 @@ def capture_model_settings_for( if prefix == "openrouter": return { - "openrouter_reasoning": _openrouter_reasoning_settings_for(model_target), + "openrouter_reasoning": dict(_openrouter_reasoning_settings_for(model_target)), } if prefix == "openai-responses": diff --git a/ergon_builtins/ergon_builtins/observability/pydantic_ai_logfire.py b/ergon_builtins/ergon_builtins/observability/pydantic_ai_logfire.py index 0f1a6c28..d9fe6bec 100644 --- a/ergon_builtins/ergon_builtins/observability/pydantic_ai_logfire.py +++ b/ergon_builtins/ergon_builtins/observability/pydantic_ai_logfire.py @@ -1,18 +1,25 @@ """Opt-in Logfire instrumentation for pydantic-ai based built-in workers.""" -from __future__ import annotations - import importlib import logging import os -from typing import Any +from typing import Protocol, cast logger = logging.getLogger(__name__) _CONFIGURED = False -def configure_pydantic_ai_logfire(*, logfire_module: Any | None = None) -> bool: +class LogfireModule(Protocol): + def configure(self, **kwargs: str | bool) -> None: ... + + def instrument_pydantic_ai(self, *, include_content: bool) -> None: ... + + +def configure_pydantic_ai_logfire( + *, + logfire_module: LogfireModule | None = None, +) -> bool: """Configure Logfire's pydantic-ai instrumentation once when explicitly enabled.""" global _CONFIGURED if os.environ.get("ERGON_LOGFIRE_PYDANTIC_AI") != "1": @@ -21,17 +28,17 @@ def configure_pydantic_ai_logfire(*, logfire_module: Any | None = None) -> bool: return True if logfire_module is None: - logfire_module = importlib.import_module("logfire") + logfire_module = cast(LogfireModule, importlib.import_module("logfire")) - kwargs = { + kwargs: dict[str, str | bool] = { "send_to_logfire": "if-token-present", "service_name": os.environ.get("ERGON_LOGFIRE_SERVICE_NAME", "ergon-builtins"), "environment": os.environ.get("ERGON_LOGFIRE_ENVIRONMENT", "local"), - "config_dir": os.environ.get("ERGON_LOGFIRE_CONFIG_DIR"), "console": False, } - if kwargs["config_dir"] is None: - kwargs.pop("config_dir") + config_dir = os.environ.get("ERGON_LOGFIRE_CONFIG_DIR") + if config_dir is not None: + kwargs["config_dir"] = config_dir logfire_module.configure(**kwargs) logfire_module.instrument_pydantic_ai(include_content=True) diff --git a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py index b739b224..bbf222dc 100644 --- a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py @@ -3,8 +3,9 @@ import json import logging -from collections.abc import AsyncGenerator -from typing import Any, Self +from collections.abc import AsyncGenerator, Callable +from types import NoneType +from typing import Any, Self, cast from uuid import UUID from ergon_core.api import BenchmarkTask, Worker, WorkerContext, WorkerOutput @@ -19,6 +20,7 @@ from pydantic import BaseModel from pydantic_ai import Agent from pydantic_ai.messages import ModelMessage +from pydantic_ai.tools import Tool from sqlmodel import Session from ergon_builtins.common.llm_context.adapters.pydantic_ai import ( @@ -30,6 +32,8 @@ logger = logging.getLogger(__name__) +AgentTool = Tool[object] | Callable[..., object] + class _AgentOutput(BaseModel): """Structured output the ReAct agent returns at the end of a run.""" @@ -59,12 +63,12 @@ def __init__( model: str | None, task_id: UUID, sandbox_id: str, - tools: list[Any], + tools: list[AgentTool], system_prompt: str | None, max_iterations: int, ) -> None: super().__init__(name=name, model=model, task_id=task_id, sandbox_id=sandbox_id) - self.tools: list[Any] = tools + self.tools: list[AgentTool] = tools self.system_prompt: str | None = system_prompt self.max_iterations: int = max_iterations self._seed_messages: list[ModelMessage] | None = None @@ -92,14 +96,17 @@ async def _run_agent( resolved = resolve_model_target(self.model) configure_pydantic_ai_logfire() agent_deps = self.build_agent_deps(context) - deps_type = type(agent_deps) if agent_deps is not None else None - - agent: Agent[Any, _AgentOutput] = Agent( - model=resolved.model, - instructions=self.system_prompt or None, - tools=self.tools, - output_type=_AgentOutput, - deps_type=deps_type, + deps_type = type(agent_deps) if agent_deps is not None else NoneType + + agent = cast( + Agent[Any, _AgentOutput], + Agent( + model=resolved.model, + instructions=self.system_prompt or None, + tools=self.tools, + output_type=_AgentOutput, + deps_type=cast(type[Any], deps_type), + ), ) task_prompt = _format_task(task) diff --git a/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py b/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py index 2b590ac5..59cb92c5 100644 --- a/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py +++ b/ergon_builtins/ergon_builtins/workers/baselines/tool_budget.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any, Literal from pydantic import BaseModel, Field diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py index 50a92acb..33433d14 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py @@ -106,7 +106,10 @@ def __init__( max_iterations=60, ) self._agent_deps = AgentToolBudgetDeps( - tool_budget=AgentToolBudgetState(**_TOOL_BUDGET_LIMITS), + tool_budget=AgentToolBudgetState( + max_workflow_tool_calls=_TOOL_BUDGET_LIMITS["max_workflow_tool_calls"], + max_other_tool_calls=_TOOL_BUDGET_LIMITS["max_other_tool_calls"], + ), ) def build_agent_deps(self, context: WorkerContext) -> AgentToolBudgetDeps: @@ -154,7 +157,10 @@ async def publisher_sync() -> list[RunResourceView]: graph_tools = graph_toolkit.build_tools() self._agent_deps = AgentToolBudgetDeps( - tool_budget=AgentToolBudgetState(**_TOOL_BUDGET_LIMITS), + tool_budget=AgentToolBudgetState( + max_workflow_tool_calls=_TOOL_BUDGET_LIMITS["max_workflow_tool_calls"], + max_other_tool_calls=_TOOL_BUDGET_LIMITS["max_other_tool_calls"], + ), ) self.tools = [*rr_tools, *graph_tools] diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py index f334e5f8..6dd7dee1 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py @@ -114,7 +114,10 @@ def __init__( max_iterations=60, ) self._agent_deps = AgentToolBudgetDeps( - tool_budget=AgentToolBudgetState(**_TOOL_BUDGET_LIMITS), + tool_budget=AgentToolBudgetState( + max_workflow_tool_calls=_TOOL_BUDGET_LIMITS["max_workflow_tool_calls"], + max_other_tool_calls=_TOOL_BUDGET_LIMITS["max_other_tool_calls"], + ), ) def build_agent_deps(self, context: WorkerContext) -> AgentToolBudgetDeps: @@ -160,7 +163,10 @@ async def publisher_sync() -> list[RunResourceView]: budgeted=True, ) self._agent_deps = AgentToolBudgetDeps( - tool_budget=AgentToolBudgetState(**_TOOL_BUDGET_LIMITS), + tool_budget=AgentToolBudgetState( + max_workflow_tool_calls=_TOOL_BUDGET_LIMITS["max_workflow_tool_calls"], + max_other_tool_calls=_TOOL_BUDGET_LIMITS["max_other_tool_calls"], + ), ) self.tools = [*rr_toolkit.build_tools(), *graph_toolkit.build_tools(), workflow_tool] diff --git a/ergon_cli/ergon_cli/commands/workflow.py b/ergon_cli/ergon_cli/commands/workflow.py index 98014bad..4560a1ee 100644 --- a/ergon_cli/ergon_cli/commands/workflow.py +++ b/ergon_cli/ergon_cli/commands/workflow.py @@ -103,6 +103,22 @@ def build_workflow_parser() -> argparse.ArgumentParser: return parser +def _dispatch_workflow_command( + args: argparse.Namespace, + *, + context: WorkflowCommandContext, + session: Session, + service: WorkflowService, +) -> WorkflowCommandOutput: + if args.group == "inspect": + return _handle_inspect(args, context=context, session=session, service=service) + if args.group == "manage": + return asyncio.run( # slopcop: ignore[no-async-from-sync] -- CLI/tool sync bridge + _handle_manage(args, context=context, session=session, service=service) + ) + raise ValueError(f"unsupported workflow command group: {args.group}") + + def execute_workflow_command( command: str, *, @@ -131,18 +147,16 @@ def execute_workflow_command( ) session = session_factory() try: - try: - if args.group == "inspect": - return _handle_inspect(args, context=context, session=session, service=service) - if args.group == "manage": - return asyncio.run( # slopcop: ignore[no-async-from-sync] -- CLI/tool sync bridge - _handle_manage(args, context=context, session=session, service=service) - ) - except ValueError as exc: - return WorkflowCommandOutput(stdout="", stderr=str(exc), exit_code=2) + return _dispatch_workflow_command( + args, + context=context, + session=session, + service=service, + ) + except ValueError as exc: + return WorkflowCommandOutput(stdout="", stderr=str(exc), exit_code=2) finally: _close_session(session) - raise ValueError(f"unsupported workflow command group: {args.group}") async def handle_workflow(args: argparse.Namespace) -> int: diff --git a/ergon_cli/ergon_cli/composition/__init__.py b/ergon_cli/ergon_cli/composition/__init__.py index b23ae59e..06aff535 100644 --- a/ergon_cli/ergon_cli/composition/__init__.py +++ b/ergon_cli/ergon_cli/composition/__init__.py @@ -88,9 +88,10 @@ def _build_smoke_experiment( at runtime via ``ExperimentDefinitionWorker`` lookup in ``task_execution_service._prepare_graph_native``. """ - # reason: deferred import keeps CLI startup cost on the hot path low - # (matches the pattern at the top of ``build_experiment``). + # reason: optional heavy dependency; imported only while building smoke compositions. from ergon_builtins.registry import WORKERS + + # reason: optional test-support smoke fixtures; imported only for smoke compositions. from ergon_core.test_support.smoke_fixtures.criteria.timing import ( SmokePostRootTimingRubric, ) @@ -174,6 +175,7 @@ def _build_researchrubrics_workflow_experiment( all_task_slugs = [task.task_slug for tasks in instances.values() for task in tasks] evaluators = {"default": evaluator} if "post-root" in benchmark.evaluator_requirements(): + # reason: optional test-support smoke fixtures; imported only when requested. from ergon_core.test_support.smoke_fixtures.criteria.timing import ( SmokePostRootTimingRubric, ) diff --git a/ergon_core/ergon_core/core/api/schemas.py b/ergon_core/ergon_core/core/api/schemas.py index 0d532aab..1b0e3672 100644 --- a/ergon_core/ergon_core/core/api/schemas.py +++ b/ergon_core/ergon_core/core/api/schemas.py @@ -247,4 +247,3 @@ class TrainingMetricDto(CamelModel): entropy: float | None = None completion_mean_length: float | None = None step_time_s: float | None = None - diff --git a/ergon_core/ergon_core/core/dashboard/emitter.py b/ergon_core/ergon_core/core/dashboard/emitter.py index 5fcef8a3..182cc291 100644 --- a/ergon_core/ergon_core/core/dashboard/emitter.py +++ b/ergon_core/ergon_core/core/dashboard/emitter.py @@ -16,8 +16,12 @@ ) from ergon_core.core.persistence.context.event_payloads import ContextEventType from ergon_core.core.persistence.graph.models import ( + GraphTargetType, + MutationType, RunGraphMutation, ) +from ergon_core.core.persistence.shared.types import RunId +from ergon_core.core.persistence.graph.status_conventions import NodeStatus from ergon_core.core.persistence.queries import queries from ergon_core.core.runtime.events.task_events import TaskCancelledEvent from ergon_core.core.runtime.inngest.client import inngest_client @@ -26,7 +30,7 @@ from ergon_core.core.runtime.services.cohort_stats_service import ( experiment_cohort_stats_service, ) -from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto +from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto, GraphMutationValue from ergon_core.core.utils import utcnow if TYPE_CHECKING: @@ -131,8 +135,8 @@ async def task_status_changed( # slopcop: ignore[max-function-params] run_id: UUID, task_id: UUID, task_name: str, - new_status: str, - old_status: str | None = None, + new_status: NodeStatus, + old_status: NodeStatus | None = None, parent_task_id: UUID | None = None, triggered_by: str | None = None, assigned_worker_id: UUID | None = None, @@ -359,14 +363,16 @@ async def graph_mutation(self, row: RunGraphMutation) -> None: try: record = GraphMutationRecordDto( id=row.id, - run_id=row.run_id, + run_id=cast(RunId, row.run_id), sequence=row.sequence, - mutation_type=row.mutation_type, - target_type=row.target_type, + mutation_type=cast(MutationType, row.mutation_type), + target_type=cast(GraphTargetType, row.target_type), target_id=row.target_id, actor=row.actor, - old_value=dict(row.old_value) if row.old_value else None, - new_value=dict(row.new_value), + old_value=cast(GraphMutationValue | None, dict(row.old_value)) + if row.old_value + else None, + new_value=cast(GraphMutationValue, dict(row.new_value)), reason=row.reason, created_at=row.created_at, ) diff --git a/ergon_core/ergon_core/core/persistence/graph/status_conventions.py b/ergon_core/ergon_core/core/persistence/graph/status_conventions.py index 2829baf7..c1088931 100644 --- a/ergon_core/ergon_core/core/persistence/graph/status_conventions.py +++ b/ergon_core/ergon_core/core/persistence/graph/status_conventions.py @@ -34,6 +34,7 @@ def is_terminal_node_status(status: str) -> bool: def is_blockable_node_status(status: str) -> bool: return status != RUNNING and status not in TERMINAL_STATUSES + # ── Edge status ─────────────────────────────────────────────────── # Edges are pure dependency relations (containment lives on the node). # "active" is removed — delegation edges no longer exist. diff --git a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py index d7db8d08..d0ba5ea0 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py +++ b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py @@ -127,9 +127,7 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: "message": error_msg, "exception_type": type(exc).__name__, "phase": "worker_execute", - "stack": "".join( - traceback.format_exception(type(exc), exc, exc.__traceback__) - ), + "stack": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)), "context": {}, }, ) diff --git a/ergon_core/ergon_core/core/runtime/tracing/otel.py b/ergon_core/ergon_core/core/runtime/tracing/otel.py index 51b66dc4..6e6a7921 100644 --- a/ergon_core/ergon_core/core/runtime/tracing/otel.py +++ b/ergon_core/ergon_core/core/runtime/tracing/otel.py @@ -1,6 +1,7 @@ """OpenTelemetry tracing sink.""" from datetime import UTC, datetime +from typing import Any, cast from uuid import UUID from opentelemetry import trace as otel_trace @@ -18,9 +19,11 @@ from opentelemetry.trace.span import TraceState try: - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as _OTLPSpanExporter, + ) except ImportError: - OTLPSpanExporter = None # type: ignore[assignment,misc] + _OTLPSpanExporter = None from ergon_core.core.json_types import JsonObject from ergon_core.core.runtime.tracing.attributes import datetime_to_nanos, normalize_attributes @@ -41,9 +44,11 @@ class OtelTraceSink: def __init__(self) -> None: provider = TracerProvider( resource=Resource.create({"service.name": settings.otel_service_name}), - id_generator=DeterministicIdGenerator(), + id_generator=cast(Any, DeterministicIdGenerator()), ) - exporter = OTLPSpanExporter( + if _OTLPSpanExporter is None: + raise RuntimeError("opentelemetry OTLP exporter is not installed") + exporter = _OTLPSpanExporter( endpoint=settings.otel_exporter_otlp_endpoint, insecure=settings.otel_exporter_otlp_insecure, ) @@ -116,7 +121,7 @@ def emit_span(self, span: CompletedSpan) -> None: sdk_span = self._tracer.start_span( span.name, context=parent_ctx, - attributes=attrs, + attributes=cast(Any, attrs), start_time=start_time, ) @@ -128,7 +133,7 @@ def emit_span(self, span: CompletedSpan) -> None: for event in span.events: sdk_span.add_event( event.name, - attributes=normalize_attributes(event.attributes), + attributes=cast(Any, normalize_attributes(event.attributes)), timestamp=datetime_to_nanos(event.timestamp), ) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py index 4503e634..af63e32e 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py @@ -16,6 +16,7 @@ class SmokePostRootTimingCriterion(Criterion): async def evaluate(self, context: EvaluationContext) -> CriterionResult: return CriterionResult( + slug=self.slug, name=self.name, score=1.0, passed=True, diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py index b113f2b5..f08ae3c5 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py @@ -93,6 +93,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: await self._verify_sandbox_setup(context) except CriteriaCheckError as e: return CriterionResult( + slug=self.slug, name=self.name, score=0.0, passed=False, @@ -100,6 +101,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: feedback=f"smoke criterion failed: {e}", ) return CriterionResult( + slug=self.slug, name=self.name, score=1.0, passed=True, diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py index 7e15ec30..7631cf2a 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py @@ -105,8 +105,7 @@ async def execute( yield ContextPartChunk( part=AssistantTextPart( content=( - f"{type(self).__name__}: nested children terminal " - f"{self._last_child_statuses}" + f"{type(self).__name__}: nested children terminal {self._last_child_statuses}" ), ), ) diff --git a/ergon_core/migrations/versions/e5f6a7b8c9d0_normalize_evaluation_summary_nulls.py b/ergon_core/migrations/versions/e5f6a7b8c9d0_normalize_evaluation_summary_nulls.py index 8e9d2490..391d472d 100644 --- a/ergon_core/migrations/versions/e5f6a7b8c9d0_normalize_evaluation_summary_nulls.py +++ b/ergon_core/migrations/versions/e5f6a7b8c9d0_normalize_evaluation_summary_nulls.py @@ -21,6 +21,68 @@ depends_on: Union[str, Sequence[str], None] = None +def _normalize_description(entry: dict) -> None: + criterion_description = entry.get("criterion_description") + if isinstance(criterion_description, str) and criterion_description != "": + return + + criterion_name = entry.get("criterion_name") + entry["criterion_description"] = ( + criterion_name + if isinstance(criterion_name, str) and criterion_name + else "unknown criterion" + ) + + +def _normalize_nullable_text(entry: dict, field_name: str) -> None: + if entry.get(field_name) == "": + entry[field_name] = None + else: + entry.setdefault(field_name, None) + + +def _normalize_error(entry: dict) -> None: + error = entry.get("error") + if error == "": + entry["error"] = None + elif isinstance(error, str): + entry["error"] = {"kind": error} + else: + entry.setdefault("error", None) + + +def _normalize_status(entry: dict) -> None: + if entry.get("status") in {"passed", "failed", "errored", "skipped"}: + return + + if entry.get("error") is not None: + entry["status"] = "errored" + elif entry.get("skipped_reason") is not None: + entry["status"] = "skipped" + else: + entry["status"] = "passed" if entry.get("passed") is True else "failed" + + +def _normalize_scoring(entry: dict) -> None: + entry.setdefault("weight", 1.0) + if "contribution" in entry: + return + + score = entry.get("score") + entry["contribution"] = score if isinstance(score, int | float) else 0.0 + + +def _normalize_criterion_result(entry: dict) -> None: + _normalize_description(entry) + _normalize_nullable_text(entry, "feedback") + _normalize_nullable_text(entry, "evaluation_input") + _normalize_error(entry) + _normalize_nullable_text(entry, "skipped_reason") + entry.setdefault("model_reasoning", None) + _normalize_status(entry) + _normalize_scoring(entry) + + def _normalize_summary_json(summary_json: dict) -> dict: normalized = deepcopy(summary_json) criterion_results = normalized.get("criterion_results") @@ -28,57 +90,8 @@ def _normalize_summary_json(summary_json: dict) -> dict: return normalized for entry in criterion_results: - if not isinstance(entry, dict): - continue - - criterion_description = entry.get("criterion_description") - if not isinstance(criterion_description, str) or criterion_description == "": - criterion_name = entry.get("criterion_name") - entry["criterion_description"] = ( - criterion_name - if isinstance(criterion_name, str) and criterion_name - else "unknown criterion" - ) - - if entry.get("feedback") == "": - entry["feedback"] = None - else: - entry.setdefault("feedback", None) - - if entry.get("evaluation_input") == "": - entry["evaluation_input"] = None - else: - entry.setdefault("evaluation_input", None) - - if entry.get("error") == "": - entry["error"] = None - elif isinstance(entry.get("error"), str): - entry["error"] = {"kind": entry["error"]} - else: - entry.setdefault("error", None) - - skipped_reason = entry.get("skipped_reason") - if skipped_reason == "": - entry["skipped_reason"] = None - else: - entry.setdefault("skipped_reason", None) - - entry.setdefault("model_reasoning", None) - - passed = entry.get("passed") - if entry.get("status") not in {"passed", "failed", "errored", "skipped"}: - if entry.get("error") is not None: - entry["status"] = "errored" - elif entry.get("skipped_reason") is not None: - entry["status"] = "skipped" - else: - entry["status"] = "passed" if passed is True else "failed" - - if "weight" not in entry: - entry["weight"] = 1.0 - if "contribution" not in entry: - score = entry.get("score") - entry["contribution"] = score if isinstance(score, int | float) else 0.0 + if isinstance(entry, dict): + _normalize_criterion_result(entry) return normalized diff --git a/scripts/spike_openrouter_reasoning.py b/scripts/spike_openrouter_reasoning.py index c8ce9abb..9daa3a6d 100644 --- a/scripts/spike_openrouter_reasoning.py +++ b/scripts/spike_openrouter_reasoning.py @@ -14,12 +14,13 @@ import argparse import asyncio +import importlib import os from collections import Counter from typing import Any # Register production model backends before resolving OpenRouter targets. -import ergon_builtins.registry # noqa: F401 +importlib.import_module("ergon_builtins.registry") from ergon_builtins.models.resolution import resolve_model_target from pydantic_ai import Agent from pydantic_ai.messages import ( diff --git a/tests/unit/dashboard/test_event_contract_types.py b/tests/unit/dashboard/test_event_contract_types.py index 3737d3ce..f03bf30b 100644 --- a/tests/unit/dashboard/test_event_contract_types.py +++ b/tests/unit/dashboard/test_event_contract_types.py @@ -40,7 +40,9 @@ def test_cohort_updated_event_uses_cohort_summary_dto() -> None: @pytest.mark.asyncio -async def test_task_status_emitter_uses_assigned_worker_slug(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_task_status_emitter_uses_assigned_worker_slug( + monkeypatch: pytest.MonkeyPatch, +) -> None: sent_events = [] async def send(event) -> None: diff --git a/tests/unit/state/test_openrouter_model_resolution.py b/tests/unit/state/test_openrouter_model_resolution.py index ce5d09d3..3748afab 100644 --- a/tests/unit/state/test_openrouter_model_resolution.py +++ b/tests/unit/state/test_openrouter_model_resolution.py @@ -1,7 +1,10 @@ -# Importing the builtins registry registers production model backends. -import ergon_builtins.registry # noqa: F401 +import importlib + from ergon_builtins.models.resolution import resolve_model_target +# Importing the builtins registry registers production model backends. +importlib.import_module("ergon_builtins.registry") + def test_openrouter_target_resolves_to_openrouter_provider_model() -> None: resolved = resolve_model_target("openrouter:anthropic/claude-sonnet-4.6") From 1ec99e3d4246a3c6b963805616c6570ece57005a Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:41:44 +0100 Subject: [PATCH 41/66] Consolidate recovered branch cleanup work Preserve the recovered dashboard DI, public API cleanup, and criterion contract changes in the main checkout before continuing test fixes. Made-with: Cursor --- ...26-04-28-mas-rebase-regression-recovery.md | 386 ++++ ...6-04-28-public-api-audit-and-ergonomics.md | 1556 +++++++++++++++++ .../2026-04-28-public-api-folder-plan.md | 413 +++++ .../benchmarks/gdpeval/criteria.py | 4 +- .../benchmarks/minif2f/criteria.py | 2 +- .../minif2f/rules/proof_verification.py | 8 +- .../researchrubrics/judge_criterion.py | 2 +- .../benchmarks/swebench_verified/criterion.py | 18 +- .../evaluators/criteria/code_check.py | 10 +- .../evaluators/criteria/llm_judge.py | 8 +- .../evaluators/criteria/sandbox_file_check.py | 12 +- .../evaluators/rubrics/swebench_rubric.py | 2 +- ergon_core/ergon_core/api/criterion.py | 17 +- ergon_core/ergon_core/api/handles.py | 2 +- ergon_core/ergon_core/core/api/app.py | 38 +- ergon_core/ergon_core/core/api/rollouts.py | 55 +- .../ergon_core/core/api/startup_plugins.py | 15 - .../ergon_core/core/dashboard/__init__.py | 12 +- .../ergon_core/core/dashboard/emitter.py | 8 +- .../ergon_core/core/dashboard/provider.py | 34 + .../runtime/inngest/cleanup_cancelled_task.py | 4 +- .../core/runtime/inngest/complete_workflow.py | 4 +- .../core/runtime/inngest/evaluate_task_run.py | 4 +- .../core/runtime/inngest/start_workflow.py | 4 +- .../core/runtime/inngest/worker_execute.py | 18 +- .../runtime/services/communication_service.py | 4 +- .../services/task_execution_service.py | 4 +- .../services/task_management_service.py | 12 +- .../core/sandbox/instrumentation.py | 9 +- .../smoke_fixtures/criteria/smoke_rubrics.py | 6 +- .../smoke_fixtures/criteria/timing.py | 4 +- .../smoke_base/criterion_base.py | 4 +- tests/conftest.py | 12 + .../minif2f/test_verification_integration.py | 2 +- .../swebench_verified/test_criterion.py | 12 +- .../swebench_verified/test_rubric.py | 2 +- .../swebench_verified/test_smoke_e2e.py | 2 +- tests/unit/api/test_criterion_contract.py | 35 + tests/unit/api/test_public_api_imports.py | 7 + .../architecture/test_api_runs_boundary.py | 54 + .../test_public_api_boundaries.py | 20 + tests/unit/dashboard/test_emitter_provider.py | 44 + .../runtime/test_communication_service.py | 17 +- .../test_evaluation_summary_contracts.py | 2 +- tests/unit/runtime/test_import_boundaries.py | 7 + .../test_inngest_criterion_executor.py | 4 +- .../runtime/test_rubric_evaluation_service.py | 12 +- .../test_worker_execute_output_failure.py | 12 - .../unit/smoke_base/test_minif2f_criterion.py | 2 +- .../test_researchrubrics_criterion.py | 2 +- .../smoke_base/test_swebench_criterion.py | 2 +- .../state/test_llm_judge_runtime_injection.py | 6 +- tests/unit/test_rollouts_di.py | 62 + tests/unit/test_test_harness.py | 4 +- 54 files changed, 2813 insertions(+), 188 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-28-mas-rebase-regression-recovery.md create mode 100644 docs/superpowers/plans/2026-04-28-public-api-audit-and-ergonomics.md create mode 100644 docs/superpowers/plans/2026-04-28-public-api-folder-plan.md delete mode 100644 ergon_core/ergon_core/core/api/startup_plugins.py create mode 100644 ergon_core/ergon_core/core/dashboard/provider.py create mode 100644 tests/conftest.py create mode 100644 tests/unit/api/test_criterion_contract.py create mode 100644 tests/unit/architecture/test_api_runs_boundary.py create mode 100644 tests/unit/dashboard/test_emitter_provider.py delete mode 100644 tests/unit/runtime/test_worker_execute_output_failure.py create mode 100644 tests/unit/test_rollouts_di.py diff --git a/docs/superpowers/plans/2026-04-28-mas-rebase-regression-recovery.md b/docs/superpowers/plans/2026-04-28-mas-rebase-regression-recovery.md new file mode 100644 index 00000000..f5475b3a --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-mas-rebase-regression-recovery.md @@ -0,0 +1,386 @@ +# MAS Rebase Regression Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Recover changes lost or blurred during the `feature/mas-main-rebase` merge, without undoing intentional main-branch experiment-run work. + +**Architecture:** Treat this as a rebase audit and repair plan. Definite regressions get direct test-first fixes; the older object-first `ExperimentRunHandle` / `Experiment.run()` API is intentionally retired in favor of the newer experiment definition and launch services. + +**Tech Stack:** Python 3.13, Pydantic, SQLModel, pytest, uv, Ergon core/runtime/API packages. + +--- + +## Audit Summary + +The rebase worktree is clean at `feature/mas-main-rebase` with `HEAD` at `ab28db3` (`Merge main into MAS debugger branch`). The broad cleanup survived, but two regressions need action. + +### Preserved Work + +- Public API thinning survived: removed `ergon_core.api.generation`, `json_types`, `run_resource`, `criterion_runtime`, `dependencies`, and `types`. +- Runtime homes survived: `core/runtime/resources.py`, `core/runtime/dependencies.py`, and `core/runtime/evaluation/protocols.py`. +- Context schema consolidation survived: `ContextPart`, `ContextPartChunk`, and `ContextPartChunkLog` are the core stream/log schemas; old `GenerationTurn` and old `*Payload` context-event classes are gone from core. +- File moves survived: Inngest client/registry under `core/runtime/inngest/`, sandbox under `core/sandbox/`, ResearchRubrics sandbox manager under builtins, OpenRouter budget under `tests/real_llm`, and tracing split into `core/runtime/tracing/`. +- `error_payload.py`, `build_error_json`, `RuntimeErrorPayload`, and `_worker_execute_result_from_exception` remain removed. + +### Definite Regression + +`_worker_execute_result_from_output()` has reappeared in `ergon_core/ergon_core/core/runtime/inngest/worker_execute.py`, along with `tests/unit/runtime/test_worker_execute_output_failure.py`. + +Today's intended state was: + +- No private adapter helper for `WorkerOutput -> WorkerExecuteResult`. +- Success result construction inlined at the only callsite. +- No helper-level test importing `_worker_execute_result_from_output`. + +### Intentional Retirement + +`ExperimentRunHandle` and `Experiment.run()` existed on `safety/mas-before-main-rebase`, but are absent in `feature/mas-main-rebase`. + +Current state: + +- `ergon_core/ergon_core/api/handles.py` defines only `PersistedExperimentDefinition`. +- `ergon_core/ergon_core/api/__init__.py` exports only `PersistedExperimentDefinition`, not `ExperimentRunHandle`. +- `ergon_core/ergon_core/api/experiment.py` exposes `persist()` but no `run()`. +- Main added experiment launch/read services under `core/runtime/services/experiment_*`, and that newer model is the one we want to keep. + +Decision: do **not** restore `ExperimentRunHandle` or `Experiment.run()`. Treat the older object-run API as retired. The fix is to remove stale handle/run wording and add tests that prevent the old single-run handle from returning to `ergon_core.api`. + +--- + +## Files To Touch + +### Definite Helper Regression + +- Modify: `ergon_core/ergon_core/core/runtime/inngest/worker_execute.py` +- Delete: `tests/unit/runtime/test_worker_execute_output_failure.py` +- Modify or add guard: `tests/unit/runtime/test_import_boundaries.py` or `tests/unit/architecture/test_public_api_boundaries.py` + +### Experiment Handle Retirement + +- Modify: `ergon_core/ergon_core/api/handles.py` docstring +- Modify/add API boundary test confirming no `ExperimentRunHandle` / no `Experiment.run` +- Update docs that still describe `run()` as part of the object-first authoring API. + +--- + +## Task 1: Lock In The Helper Removal Regression + +**Files:** +- Modify: `tests/unit/runtime/test_import_boundaries.py` +- Modify: `ergon_core/ergon_core/core/runtime/inngest/worker_execute.py` +- Delete: `tests/unit/runtime/test_worker_execute_output_failure.py` + +- [ ] **Step 1: Add a failing guard for deleted worker helper adapters** + +Add this test to `tests/unit/runtime/test_import_boundaries.py`: + +```python +def test_worker_execute_does_not_expose_result_adapter_helpers() -> None: + import ergon_core.core.runtime.inngest.worker_execute as worker_execute + + assert not hasattr(worker_execute, "_worker_execute_result_from_output") + assert not hasattr(worker_execute, "_worker_execute_result_from_exception") +``` + +- [ ] **Step 2: Run the guard and verify it fails before the fix** + +Run: + +```bash +uv run pytest tests/unit/runtime/test_import_boundaries.py::test_worker_execute_does_not_expose_result_adapter_helpers -q +``` + +Expected before fix: + +```text +FAILED ... assert not hasattr(worker_execute, "_worker_execute_result_from_output") +``` + +- [ ] **Step 3: Inline the success result construction** + +In `ergon_core/ergon_core/core/runtime/inngest/worker_execute.py`, remove: + +```python +def _worker_execute_result_from_output(output: WorkerOutput) -> WorkerExecuteResult: + return WorkerExecuteResult( + success=output.success, + final_assistant_message=output.output, + error=None if output.success else output.output, + ) +``` + +Then replace: + +```python +return _worker_execute_result_from_output(output) +``` + +with: + +```python +return WorkerExecuteResult( + success=output.success, + final_assistant_message=output.output, + error=None if output.success else output.output, +) +``` + +Also remove the now-unused import: + +```python +from ergon_core.api.results import WorkerOutput +``` + +- [ ] **Step 4: Delete helper-specific test** + +Delete: + +```text +tests/unit/runtime/test_worker_execute_output_failure.py +``` + +This test asserts a private helper mapping and should not survive once the helper is gone. The behavior is still covered by `worker_execute_fn` return construction and `WorkerExecuteResult` model validation. + +- [ ] **Step 5: Run focused verification** + +Run: + +```bash +uv run pytest tests/unit/runtime/test_import_boundaries.py tests/unit/runtime/test_failure_error_json.py -q +uv run ruff check ergon_core/ergon_core/core/runtime/inngest/worker_execute.py tests/unit/runtime/test_import_boundaries.py +``` + +Expected: + +```text +passed +All checks passed! +``` + +--- + +## Task 2: Lock In The New Experiment Launch Model + +**Files:** +- Inspect: `ergon_core/ergon_core/api/experiment.py` +- Inspect: `ergon_core/ergon_core/api/handles.py` +- Inspect: `ergon_core/ergon_core/core/runtime/services/run_service.py` +- Inspect: `ergon_core/ergon_core/core/runtime/services/experiment_launch_service.py` +- Inspect: `ergon_cli/ergon_cli/commands/benchmark.py` + +- [ ] **Step 1: Confirm current execution entry points** + +Run: + +```bash +rg "class ExperimentRunHandle|async def run\\(|create_experiment_run|launch" \ + ergon_core/ergon_core/api \ + ergon_core/ergon_core/core/runtime/services \ + ergon_cli/ergon_cli/commands \ + tests -n +``` + +Expected current signal: + +- `ExperimentRunHandle` appears only as a CLI-local class in `ergon_cli/ergon_cli/commands/benchmark.py`. +- `Experiment` has `persist()` but no `run()`. +- Main-branch experiment services own launch/read behavior. + +Step 1 confirms that the newer model is active: + +- `ExperimentRecord` stores the experiment campaign/sample selection. +- `ExperimentLaunchService.run_experiment()` expands one `ExperimentRecord` into many `RunRecord`s. +- `ExperimentRunResult` returns `run_ids: list[UUID]`, not a single `run_id`. +- `ergon_core.api.Experiment` remains a workflow-definition composition object with `persist()` only. + +- [ ] **Step 2: Write a guard for the retired object-run API** + +Add tests to `tests/unit/api/test_public_api_imports.py`: + +```python +def test_object_first_experiment_run_api_is_retired() -> None: + public_api = importlib.import_module("ergon_core.api") + + assert not hasattr(public_api, "ExperimentRunHandle") + assert not hasattr(public_api.Experiment, "run") +``` + +- [ ] **Step 3: Clean stale handle wording** + +Update `ergon_core/ergon_core/api/handles.py` docstring from: + +```python +"""Public lifecycle handle types returned by persist() and run().""" +``` + +to: + +```python +"""Public lifecycle handle types returned by Experiment.persist().""" +``` + +- [ ] **Step 4: Run focused API verification** + +Run: + +```bash +uv run pytest tests/unit/api/test_public_api_imports.py -q +``` + +Expected: + +```text +passed +``` + +--- + +## Task 3: Add A Rebase Recovery Guard For Historical Regressions + +**Files:** +- Modify: `tests/unit/architecture/test_public_api_boundaries.py` +- Modify: `tests/unit/runtime/test_import_boundaries.py` + +- [ ] **Step 1: Guard deleted API facade modules by module spec** + +Add to `tests/unit/architecture/test_public_api_boundaries.py`: + +```python +import importlib.util + + +def test_removed_api_facade_modules_do_not_exist() -> None: + removed_modules = ( + "ergon_core.api.generation", + "ergon_core.api.json_types", + "ergon_core.api.run_resource", + "ergon_core.api.criterion_runtime", + "ergon_core.api.dependencies", + "ergon_core.api.types", + ) + + for module_name in removed_modules: + assert importlib.util.find_spec(module_name) is None +``` + +- [ ] **Step 2: Guard worker private adapter helpers** + +Use the helper guard from Task 1. + +- [ ] **Step 3: Run architecture guards** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_public_api_boundaries.py tests/unit/runtime/test_import_boundaries.py -q +``` + +Expected: + +```text +passed +``` + +--- + +## Task 4: Final Verification + +**Files:** +- All touched files from Tasks 1-3. +- Verify: `tests/integration/smokes/test_smoke_harness.py` +- Verify: `tests/e2e/` + +- [ ] **Step 1: Run focused test group** + +Run: + +```bash +uv run pytest \ + tests/unit/api/test_public_api_imports.py \ + tests/unit/architecture/test_public_api_boundaries.py \ + tests/unit/runtime/test_import_boundaries.py \ + tests/unit/runtime/test_failure_error_json.py \ + -q +``` + +Expected: + +```text +passed +``` + +- [ ] **Step 2: Run targeted lint** + +Run: + +```bash +uv run ruff check \ + ergon_core/ergon_core/core/runtime/inngest/worker_execute.py \ + ergon_core/ergon_core/api/handles.py \ + ergon_core/ergon_core/api/__init__.py \ + ergon_core/ergon_core/api/experiment.py \ + tests/unit/api/test_public_api_imports.py \ + tests/unit/architecture/test_public_api_boundaries.py \ + tests/unit/runtime/test_import_boundaries.py +``` + +Expected: + +```text +All checks passed! +``` + +- [ ] **Step 3: Run local integration/e2e acceptance for the newer cohort -> experiment -> run model** + +Use this as the main system-level confidence metric for the rebase: + +> A local checkout can define an experiment through the newer cohort/experiment model, launch runs for selected samples, drive those runs through the runtime, persist graph/evaluation/resource outputs, and pass the e2e smoke path without relying on retired `Experiment.run()` / `ExperimentRunHandle`. + +Run the local smoke/e2e set used by this branch: + +```bash +uv run pytest tests/integration/smokes/test_smoke_harness.py -q +uv run pytest tests/e2e -q +``` + +Expected: + +```text +passed +``` + +If the e2e suite requires local services, start the normal local stack first, then rerun the same commands. A failure here is a blocker unless it is a documented environment prerequisite rather than a model/API regression. + +- [ ] **Step 4: Check git diff for scope** + +Run: + +```bash +git diff --stat +git diff --name-status +``` + +Expected changed files should be limited to: + +- `docs/superpowers/plans/2026-04-28-mas-rebase-regression-recovery.md` +- `ergon_core/ergon_core/core/runtime/inngest/worker_execute.py` +- `tests/unit/runtime/test_import_boundaries.py` +- `tests/unit/runtime/test_worker_execute_output_failure.py` deleted +- plus the accept-main guard/docstring files from Task 2. + +--- + +## Non-Goals + +- Do not reintroduce `ergon_core.api.generation`, `json_types`, `run_resource`, `criterion_runtime`, `dependencies`, or `types`. +- Do not reintroduce `error_payload.py`, `build_error_json`, or `RuntimeErrorPayload`. +- Do not undo main's experiment-run domain model or revive `ExperimentRunHandle` / `Experiment.run()`. +- Do not edit historical docs/RFCs unless they are actively misleading for the current public API. + +## Completion Criteria + +- `_worker_execute_result_from_output` and `_worker_execute_result_from_exception` are absent. +- `test_worker_execute_output_failure.py` is deleted or rewritten to avoid private helper imports. +- Public API state around `ExperimentRunHandle` is explicit and tested as intentionally absent. +- Local smoke/e2e tests pass through the newer `cohort -> experiment -> run` model without using the retired object-run API. +- Focused pytest and ruff checks pass. diff --git a/docs/superpowers/plans/2026-04-28-public-api-audit-and-ergonomics.md b/docs/superpowers/plans/2026-04-28-public-api-audit-and-ergonomics.md new file mode 100644 index 00000000..6c7de628 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-public-api-audit-and-ergonomics.md @@ -0,0 +1,1556 @@ +# Public API Audit And Ergonomics Working Doc + +This is a working document for deciding what belongs in `ergon_core.api`, what should move inward to `ergon_core.core`, and what concepts can be merged so the API is easier for students and benchmark authors to use. + +The goal is not to make the public API artificially tiny. The goal is to make it honest. A public symbol should either be: + +- something a benchmark author uses to describe work, +- something a worker author uses to solve work, +- something an evaluator author uses to score work, +- or a deliberately documented advanced extension point. + +Everything else should probably be core, CLI, dashboard, persistence, or runtime plumbing. + +## Current Public API Root + +`ergon_core.api.__all__` currently exports: + +```python +Benchmark +BenchmarkDeps +BenchmarkTask +Criterion +CriterionResult +CriteriaCheckError +DependencyError +EvaluationContext +Evaluator +Experiment +EmptyTaskPayload +PersistedExperimentDefinition +Rubric +TaskEvaluationResult +Worker +WorkerContext +WorkerOutput +WorkerSpec +``` + +Submodule-only public-ish symbols currently used or plausibly imported: + +```python +CriterionScoreSpec +CriterionObservation +CriterionObservationMessage +``` + +Important existing boundary tests: + +- `tests/unit/api/test_public_api_imports.py` already asserts that runtime/tooling concepts like `RunResourceView`, `CriterionRuntime`, `CommandResult`, `SandboxResult`, and `Tool` are not exposed at the root. +- `tests/unit/architecture/test_public_api_boundaries.py` already protects against restoring deleted facade modules like `api.generation`, `api.json_types`, `api.run_resource`, `api.criterion_runtime`, `api.dependencies`, and `api.types`. + +That means the codebase already wants `ergon_core.api` to stay authoring-scoped. The current issue is that some exported authoring-looking objects still pull runtime/persistence concepts through the side door. + +## Current Mental Model + +The current public API effectively asks users to understand this: + +```text +Benchmark -> BenchmarkTask -> Experiment -> WorkerSpec -> persisted definition -> run +Worker -> WorkerContext -> streamed core generation chunks -> WorkerOutput +Criterion -> EvaluationContext -> core CriterionRuntime -> CriterionResult +Evaluator/Rubric -> TaskEvaluationResult +``` + +The student-facing model we probably want is closer to: + +```text +Benchmark -> Task +Worker solves Task +Criterion checks WorkerOutput +Rubric combines Criteria +Core handles experiments, runs, cohorts, persistence, dispatch, and dashboards +``` + +## Usage Map At A Glance + +### CLI + +The built-in CLI imports only a small part of `ergon_core.api` directly: + +- `ergon_cli/ergon_cli/composition/__init__.py` + - imports `Experiment` + - imports `WorkerSpec` +- `ergon_cli/ergon_cli/onboarding/profile.py` + - imports `BenchmarkDeps` + +The CLI otherwise reaches straight into `ergon_core.core` for: + +- DB setup and sessions, +- telemetry models such as `RunRecord`, +- `create_run`, +- cohort resolution, +- Inngest event dispatch, +- experiment define/launch/read services, +- workflow services, +- runtime settings. + +This is a useful signal. `ergon_core.api` is not really the CLI API today. The CLI already operates at the application/runtime layer. + +### Built-ins + +`ergon_builtins` uses the public API heavily as an extension-authoring kit: + +- Benchmarks subclass `Benchmark` and create `BenchmarkTask`. +- Workers subclass `Worker` and receive `WorkerContext`. +- Criteria subclass `Criterion`, receive `EvaluationContext`, and return `CriterionResult`. +- Rubrics subclass `Rubric` and return `TaskEvaluationResult`. +- Registries type their maps as `Benchmark`, `Evaluator`, and `Worker`. +- Onboarding metadata uses `BenchmarkDeps`. + +This is the strongest argument that `Benchmark`, `BenchmarkTask`, `Worker`, `WorkerContext`, `WorkerOutput`, `Criterion`, `CriterionResult`, `CriterionScoreSpec`, `Rubric`, and `TaskEvaluationResult` should remain public or have very deliberate replacements. + +### Core Runtime + +Core runtime imports public API types in several places: + +- `core/runtime/inngest/worker_execute.py` + - uses `BenchmarkTask`, `EmptyTaskPayload`, `WorkerContext` +- `core/runtime/evaluation/inngest_executor.py` + - uses `Criterion`, `EvaluationContext`, `CriterionResult`, `WorkerOutput`, `BenchmarkTask` +- `core/runtime/evaluation/evaluation_schemas.py` + - uses `Criterion` +- `core/runtime/services/rubric_evaluation_service.py` + - uses `Evaluator`, `CriterionResult`, `TaskEvaluationResult`, `BenchmarkTask` +- `core/runtime/services/experiment_persistence_service.py` + - uses `Rubric`, `PersistedExperimentDefinition`, and type-checks `Experiment` +- `core/runtime/services/experiment_launch_service.py` + - uses `Benchmark`, `Evaluator`, `Experiment`, `PersistedExperimentDefinition`, `BenchmarkTask`, `WorkerSpec` +- `core/runtime/services/experiment_definition_service.py` + - uses `Benchmark`, `BenchmarkTask` +- `core/runtime/services/run_service.py` + - uses `PersistedExperimentDefinition` + +Some of that is fine: core runtime naturally consumes public authoring objects. But the reverse direction is more concerning: public API modules also import core runtime/persistence modules. + +### Tests + +Tests use almost every current public type: + +- API contract tests cover imports and public API boundary behavior. +- Runtime tests instantiate criteria, rubrics, contexts, tasks, and result models. +- Built-in benchmark tests instantiate `Benchmark`, `BenchmarkTask`, `BenchmarkDeps`, `EvaluationContext`, `WorkerOutput`, and result models. +- Worker tests use `WorkerContext`, `BenchmarkTask`, and `EmptyTaskPayload`. +- Runtime service tests use `PersistedExperimentDefinition`. + +This means simplification should be staged. Move internal users first, leave compatibility imports where useful, then adjust tests around the intended boundary. + +## Public File Inventory + +```text +ergon_core/ergon_core/api/ + __init__.py + exports the object-first public surface + + benchmark.py + Benchmark base class + currently also validates required packages via core runtime dependencies + + benchmark_deps.py + BenchmarkDeps onboarding metadata + + task_types.py + EmptyTaskPayload + BenchmarkTask + + worker.py + Worker base class + currently imports core generation chunk types + currently reads persisted context events to build default output + + worker_context.py + WorkerContext execution identity model + + worker_spec.py + WorkerSpec config-time registry descriptor + imports ergon_builtins registry during validation + + criterion.py + Criterion base class + currently validates required packages via core runtime dependencies + + evaluation_context.py + EvaluationContext for criteria + currently exposes core CriterionRuntime protocol as a field + + evaluator.py + Evaluator base class + Rubric concrete class + currently validates required packages via core runtime dependencies + + results.py + WorkerOutput + CriterionScoreSpec + CriterionObservationMessage + CriterionObservation + CriterionResult + TaskEvaluationResult + currently imports core JsonObject + + experiment.py + Experiment composition root + validates object graph + persists through core ExperimentPersistenceService + + handles.py + PersistedExperimentDefinition handle returned by Experiment.persist() + imports core utcnow helper + + errors.py + DependencyError + CriteriaCheckError +``` + +## Symbol By Symbol Review + +### `Benchmark` + +Current role: + +- Public base class for benchmark authors. +- Owns `type_slug`, `task_payload_model`, `build_instances()`, `evaluator_requirements()`, `parse_task_payload()`, and dependency validation. + +Where used: + +- Built-in benchmarks: MiniF2F, SWE-Bench Verified, ResearchRubrics, GDPEval. +- Core experiment definition and launch services. +- Registries type benchmark constructors. +- Tests for benchmark contracts and runtime services. + +Keep in public API? + +- Yes. + +Concerns: + +- The name is good for benchmark authors. +- `build_instances()` returning `Mapping[str, Sequence[BenchmarkTask]]` introduces "instance" as an extra concept. That may be necessary for benchmark datasets, but it is one more noun. +- `evaluator_requirements()` exposes evaluator slot binding to benchmark authors. +- `validate()` imports `core.runtime.dependencies.check_packages`. + +Possible cleanup: + +- Keep `Benchmark` public. +- Consider making `evaluator_requirements()` advanced or replacing it with a simpler `default_evaluator_slots = ("default",)` class var. +- Decide whether benchmark authors should declare dependency metadata as: + - `required_packages` plus `install_hint`, + - `onboarding_deps`, + - or one consolidated `requirements` object. +- Move dependency validation implementation inward so `api.benchmark` does not import core runtime. + +Decision question: + +- Should a student writing a benchmark need to know about evaluator binding keys, or should benchmarks just produce tasks and let the experiment/CLI layer attach rubrics? + +### `BenchmarkTask` And `EmptyTaskPayload` + +Current role: + +- `BenchmarkTask` is the public task object passed to workers and criteria. +- `EmptyTaskPayload` is the default Pydantic payload when a benchmark has no structured task data. + +Where used: + +- All built-in benchmarks create `BenchmarkTask`. +- Built-in workers consume `BenchmarkTask`. +- Built-in criteria and rubrics receive task objects. +- Core runtime reconstructs `BenchmarkTask` from persisted task rows. +- Many tests instantiate it directly. + +Keep in public API? + +- Yes. + +Concerns: + +- The name `BenchmarkTask` is precise but slightly more formal than necessary for students. +- It contains `instance_key`, `parent_task_slug`, `dependency_task_slugs`, and `evaluator_binding_keys`, which are runtime/workflow concepts mixed into the authoring task model. + +Possible cleanup: + +- Keep `BenchmarkTask` for compatibility. +- Consider a friendlier alias: + +```python +Task = BenchmarkTask +``` + +- Longer term, split: + - public `Task`: slug, description, payload, + - advanced/internal `WorkflowTaskSpec`: parent/dependencies/evaluator bindings/instance key. + +Decision question: + +- Are task dependencies and evaluator bindings part of the beginner benchmark-authoring story, or are they an advanced workflow story? + +### `Worker` + +Current role: + +- Public base class for workers. +- Authors implement `execute(task, context=...)`. +- `execute()` yields `ContextPartChunk` objects. +- Default `get_output()` reads context events from the database and extracts the last assistant text. + +Where used: + +- Built-in ReAct worker and training stub worker subclass it. +- Smoke fixtures subclass it. +- Registries type worker constructors. +- Core runtime instantiates workers in `worker_execute.py`. +- Tests assert worker contracts. + +Keep in public API? + +- Yes, but slim it down. + +Concerns: + +- `api.worker` imports: + - `core.generation.AssistantTextPart` + - `core.generation.ContextPartChunk` + - `core.persistence.context.repository.ContextEventRepository` + - `core.persistence.shared.db.get_session` + - `core.runtime.dependencies.check_packages` +- That means the public base class knows persistence and generation internals. +- Students writing a worker must understand streaming chunks, not just "return an answer". + +Possible cleanup: + +- Keep `Worker` public. +- Move DB-backed default output extraction to core runtime, probably near `worker_execute.py`. +- Decide whether beginner workers can implement a simpler method: + +```python +async def run(self, task: Task, context: WorkerContext) -> WorkerOutput: + ... +``` + +while advanced workers implement streaming: + +```python +async def execute(self, task: Task, *, context: WorkerContext) -> AsyncGenerator[ContextPartChunk, None]: + ... +``` + +- If streaming remains public, either: + - intentionally export the chunk type as an advanced public type, + - or define a small public event/chunk model that core adapts into context events. + +Decision question: + +- Should the student-facing worker API be "return a WorkerOutput" first, with streaming as advanced, or should all workers remain streaming-first? + +### `WorkerContext` + +Current role: + +- Public model passed to `Worker.execute()`. +- Contains `run_id`, `definition_id`, `task_id`, `execution_id`, `sandbox_id`, `node_id`, and metadata. + +Where used: + +- Built-in workers. +- Built-in tools such as workflow CLI tooling. +- Core runtime worker execution. +- Tests. + +Keep in public API? + +- Yes, but possibly with fewer fields. + +Concerns: + +- `definition_id` and `node_id` are graph/runtime concepts. +- `task_id` is nullable for dynamic subtasks, while `execution_id` is always present. That distinction is important to core but awkward to explain to students. + +Possible cleanup: + +- Public `WorkerContext` could expose: + - `run_id` + - `task_id` or `execution_id` + - `sandbox_id` + - `metadata` +- Internal `CoreWorkerContext` could add: + - `definition_id` + - `node_id` + - static-vs-dynamic task identity. + +Decision question: + +- Which IDs do worker authors actually need in normal code? If most only need `sandbox_id` and maybe `execution_id`, hide the rest. + +### `WorkerOutput` + +Current role: + +- Public result model for worker completion. +- Contains `output`, `success`, and metadata. + +Where used: + +- Built-in workers return it. +- Criteria receive it through `EvaluationContext`. +- Core evaluation executor wraps agent reasoning into it. +- Tests instantiate it. + +Keep in public API? + +- Yes. + +Concerns: + +- Field name `output` is generic but probably fine. +- `success` is useful but can overlap with runtime execution status. + +Possible cleanup: + +- Keep as-is unless we introduce a simpler non-streaming worker API. +- If worker runtime status and worker semantic success diverge, document that `success` means "worker produced a usable answer", not "the process did not crash". + +Decision question: + +- Do we want `WorkerOutput.output` to stay a single string, or should structured outputs become first-class? + +### `Criterion` + +Current role: + +- Public base class for atomic evaluation units. +- Authors implement `evaluate(context) -> CriterionResult`. + +Where used: + +- Built-in criteria for SWE-Bench, MiniF2F, ResearchRubrics, generic code checks, LLM judge, sandbox file check. +- Smoke fixtures. +- Core evaluation executor. +- Core evaluation schemas store `Criterion` in `CriterionSpec`. +- Tests. + +Keep in public API? + +- Yes. + +Concerns: + +- `Criterion.evaluate()` depends on `EvaluationContext`, which currently exposes core runtime capability plumbing. +- `validate()` imports core dependency checking. + +Possible cleanup: + +- Keep `Criterion` public. +- Simplify the context it receives. +- Move dependency checking inward or expose it as a small public helper independent of `core`. + +Decision question: + +- Should criteria own sandbox/resource access directly through context helper methods, or should they receive a separate capability object? + +### `EvaluationContext` + +Current role: + +- Public context passed to `Criterion.evaluate()`. +- Contains run/task/execution IDs, `BenchmarkTask`, `WorkerOutput`, sandbox ID, metadata, and optional runtime capability. + +Where used: + +- Built-in criteria. +- Smoke criteria. +- Core Inngest criterion executor. +- Tests for runtime injection and criterion contracts. + +Keep in public API? + +- Probably yes short-term, but redesign it. + +Concerns: + +- It imports `core.runtime.evaluation.protocols.CriterionRuntime`. +- The public field `runtime` means criterion authors can see an internal protocol rather than a stable student-facing capability. +- It duplicates some identity with `WorkerContext`. + +Possible cleanup: + +- Keep the name `EvaluationContext` if we want stability. +- Change the implementation so it owns public helper methods: + +```python +await context.execute_code("pytest -q") +await context.read_resource("answer.txt") +await context.read_resource_by_id(resource_id) +``` + +- Store the internal runtime in a private field, not as a public typed protocol. +- Or rename to `CriterionContext` if we want "criterion evaluates with criterion context" instead of a broader evaluation context. + +Decision question: + +- Is `EvaluationContext` the right public name, or is `CriterionContext` easier for students? + +### `CriterionScoreSpec` + +Current role: + +- Public-ish score range model for criteria. +- Not exported from `ergon_core.api.__all__`, but imported from `ergon_core.api.results` by tests and built-ins. + +Where used: + +- Criteria constructors. +- MiniF2F proof verification. +- Code check and LLM judge criteria. +- Runtime tests. + +Keep in public API? + +- Yes, if criteria remain configurable with score ranges. + +Concerns: + +- It is public by usage but not top-level exported. +- If top-level exports are the documented API, this mismatch is confusing. + +Possible cleanup: + +- Either export it at the root: + +```python +from ergon_core.api import CriterionScoreSpec +``` + +- Or document `ergon_core.api.results.CriterionScoreSpec` as advanced. + +Decision question: + +- Do we want all common authoring types available from `ergon_core.api`, or do we want submodules for less common result/config types? + +### `CriterionResult` + +Current role: + +- Public result of a single criterion. +- Includes score, pass/fail, weight, feedback, evidence IDs, observations, errors, and metadata. + +Where used: + +- Built-in criteria return it. +- Rubrics aggregate it. +- Core evaluation executor returns it from each criterion step. +- Evaluation persistence converts it into persisted summaries. +- Tests. + +Keep in public API? + +- Yes. + +Concerns: + +- It is fairly large for students. +- It overlaps with internal `CriterionResultEntry` in `core.persistence.telemetry.evaluation_summary`. + +Possible cleanup: + +- Keep public `CriterionResult`. +- Keep persisted `CriterionResultEntry` internal. +- Centralize conversion in a core adapter so authors only learn `CriterionResult`. +- Consider helper constructors: + +```python +CriterionResult.pass_(slug="...", score=1.0, feedback="...") +CriterionResult.fail(slug="...", feedback="...") +``` + +Decision question: + +- Should we add helper constructors to reduce boilerplate in student-written criteria? + +### `CriterionObservation` And `CriterionObservationMessage` + +Current role: + +- Structured observation models nested inside `CriterionResult`. +- Capture prompt messages, evidence resource/action IDs, model details, and output. + +Where used: + +- ResearchRubrics judge criterion and LLM judge criterion. +- Evaluation summary persistence imports `CriterionObservation`. +- Tests likely inspect summary contracts. + +Keep in public API? + +- Keep in `results.py`, but maybe not root export. + +Concerns: + +- This is useful for advanced LLM-as-judge and audit trails. +- It may be too detailed for the beginner path. +- It imports or depends on JSON object typing from core through `results.py`. + +Possible cleanup: + +- Keep as advanced result detail. +- Move JSON type alias local to public API or use `dict[str, object]` style. + +Decision question: + +- Do students need to produce structured observations, or is this mainly for built-in LLM judges and dashboard evidence? + +### `Rubric` + +Current role: + +- Public concrete evaluator with a fixed list of criteria. +- Aggregates criterion scores with weighted average. + +Where used: + +- Built-in rubrics. +- Smoke rubrics. +- Core persistence checks whether an evaluator is a `Rubric` to snapshot criteria names. +- Core runtime service evaluates via `Evaluator` interface. +- Tests. + +Keep in public API? + +- Yes. + +Concerns: + +- It subclasses `Evaluator`, so users see both `Evaluator` and `Rubric`. +- Public `Rubric` is simple, but `RubricEvaluationService` in core has a similar name and is a runtime runner. +- Built-ins like GDPEval subclass `Rubric` but implement staged gating, which stretches the fixed-list weighted-average base concept. + +Possible cleanup: + +- Make `Rubric` the primary student-facing evaluation concept. +- Consider an explicit `WeightedRubric` name if we add multiple rubric types. +- Rename core `RubricEvaluationService` to `TaskEvaluationService` or `EvaluationRunner` to avoid confusing public rubric with internal service. + +Decision question: + +- Is `Rubric` always "a thing with criteria", or should `Evaluator` be the primary abstraction and `Rubric` just one implementation? + +### `Evaluator` + +Current role: + +- Public ABC for objects that select criteria for a task and aggregate criterion results. +- `Rubric` subclasses it. + +Where used: + +- Built-in registry typing. +- Core evaluation service accepts `Evaluator`. +- Core launch service builds evaluator bindings. +- Custom built-in rubrics inherit through `Rubric`. + +Keep in public API? + +- Maybe. + +Concerns: + +- It is a powerful extension point, but it adds another noun for students. +- Most authors probably need `Rubric`, not arbitrary dynamic evaluators. +- ResearchRubrics does need task-specific criteria via `criteria_for(task)`, which is an evaluator behavior. + +Possible cleanup: + +- Keep `Evaluator` for advanced users. +- Do not feature it in beginner docs. +- Potentially move it to `ergon_core.api.advanced` while `Rubric` stays root-exported. +- Or keep it root-exported because registries and dynamic task-specific rubrics already rely on it. + +Decision question: + +- Do we want external users to write custom dynamic evaluators, or only criteria and rubrics? + +### `TaskEvaluationResult` + +Current role: + +- Public aggregated result for one task after criteria run. + +Where used: + +- Rubrics return it. +- Core runtime persists it. +- Tests. + +Keep in public API? + +- Yes if custom rubrics/evaluators remain public. + +Concerns: + +- It overlaps with `EvaluationSummary`, which is internal persisted/dashboard state. + +Possible cleanup: + +- Keep public. +- Make `EvaluationSummary` clearly internal. +- Add adapter for persistence. + +Decision question: + +- Should rubric authors directly construct `TaskEvaluationResult`, or should Rubric have simpler aggregation hooks? + +### `Experiment` + +Current role: + +- Public composition root binding a benchmark, worker specs, evaluator bindings, assignments, and metadata. +- Validates the object graph. +- Persists itself by lazy-importing `ExperimentPersistenceService` from core. + +Where used: + +- CLI composition builds `Experiment`. +- Core launch service builds a temporary single-sample `Experiment`. +- Core persistence service type-checks it. +- Tests cover launch/persistence behavior. + +Keep in public API? + +- Open question. + +Argument to keep: + +- It is a natural word for users: "I want to run an experiment." +- It provides one object that composes benchmark, workers, and evaluators. +- CLI composition already uses it. + +Argument to move or de-emphasize: + +- It is not an authoring primitive like `Benchmark`, `Worker`, or `Criterion`. +- It exposes binding keys, assignments, evaluator maps, and worker specs. +- `persist()` makes public API depend on core persistence. +- There are already core concepts called `ExperimentRecord` and `ExperimentDefinition`, so the word "Experiment" is overloaded. + +Possible cleanup: + +- Short-term: keep exported for compatibility. +- Medium-term: remove `persist()` from the public object. Use a core service: + +```python +definition = experiment_service.persist(experiment) +``` + +- Long-term: decide whether public users should build `Experiment` directly or use a simpler CLI/app facade: + +```python +ergon.define( + benchmark="minif2f", + worker="react", + rubric="minif2f", + model="openai:gpt-4o", +) +``` + +Decision question: + +- Is `Experiment` a public user composition object, or an internal runtime definition draft? + +My current leaning: + +- Keep `Experiment` public short-term, but make it pure composition with no persistence method. +- If the beginner docs do not need it, do not root-feature it. + +### `WorkerSpec` + +Current role: + +- Config-time descriptor for worker binding. +- Contains `worker_slug`, `name`, and `model`. +- Validates worker slug against `ergon_builtins.registry.WORKERS`. + +Where used: + +- CLI composition. +- Core launch service. +- Experiment composition and persistence. +- Tests. + +Keep in public API? + +- Probably not as a beginner concept. + +Concerns: + +- It is registry/config plumbing. +- It imports builtins registry during validation. +- It exists because live `Worker` requires runtime IDs and cannot be used at config time. + +Possible cleanup: + +- Move to core composition. +- Keep compatibility import for now. +- Replace public construction with simpler facade args: + +```python +worker="researchrubrics-workflow-cli-react" +model="openai:gpt-4o" +``` + +Decision question: + +- Do external users need to build multi-worker assignment graphs manually, or can that be an advanced/core composition feature? + +### `PersistedExperimentDefinition` + +Current role: + +- Handle returned by `Experiment.persist()`. +- Contains `definition_id`, benchmark type, worker/evaluator bindings, counts, created timestamp, and metadata. + +Where used: + +- CLI benchmark command renders it and uses it to create a run. +- Core run service takes it. +- Core launch service returns it from workflow definition factory. +- Runtime tests instantiate it. + +Keep in public API? + +- Probably not as student authoring API. + +Concerns: + +- It is a persistence/launch handle, not an authoring concept. +- Its name overlaps with core `ExperimentDefinition` table rows. + +Possible cleanup: + +- Move to core composition or core service DTOs. +- Consider rename: + - `WorkflowDefinitionHandle` + - `DefinitionHandle` + - `PersistedDefinition` +- Keep compatibility import until CLI/core imports are migrated. + +Decision question: + +- Should users ever see persisted definition handles directly, or should they see run IDs/status objects from CLI/app services? + +### `BenchmarkDeps` + +Current role: + +- Onboarding requirements for a benchmark: E2B, extras, optional keys. + +Where used: + +- Built-in benchmark class vars. +- CLI onboarding profile. +- Benchmark contract tests. + +Keep in public API? + +- Maybe, but simplify or rehome. + +Concerns: + +- It duplicates conceptually with `required_packages` and `install_hint`. +- It is not about defining benchmark tasks. It is about onboarding/install/config. +- The `Benchmark` docstring says subclasses must set `onboarding_deps`, but `Benchmark` itself does not define/enforce that class var. + +Possible cleanup: + +- Merge into a single public metadata object: + +```python +requirements = BenchmarkRequirements( + packages=("datasets", "huggingface_hub"), + extras=("ergon-builtins[data]",), + env_keys=("HF_API_KEY",), + e2b=True, +) +``` + +- Or keep `BenchmarkDeps` but move to `ergon_core.api.onboarding`. + +Decision question: + +- Should install/runtime dependencies and onboarding prompts be one concept or two? + +### `DependencyError` + +Current role: + +- Raised when required packages are missing. + +Where used: + +- Public ABC validation methods. +- Tests may catch or assert dependency behavior. + +Keep in public API? + +- Maybe. + +Concerns: + +- If dependency validation moves inward, public users may not need this exception. +- But users might want to catch it around benchmark validation. + +Possible cleanup: + +- Keep if public `.validate()` methods stay. +- Move if validation becomes core launch-time behavior. + +Decision question: + +- Is dependency validation part of authoring, or only part of launching/running? + +### `CriteriaCheckError` + +Current role: + +- Domain-level exception criteria can raise from helpers and catch inside `evaluate()` to return a failed `CriterionResult`. + +Where used: + +- Smoke fixture criteria. +- Built-in criterion tests. + +Keep in public API? + +- Yes. + +Concerns: + +- The name uses plural "Criteria" even though a single criterion raises it. + +Possible cleanup: + +- Keep for compatibility. +- Consider alias: + +```python +CriterionCheckError = CriteriaCheckError +``` + +Decision question: + +- Is the plural name worth correcting with an alias, or not worth the churn? + +## Boundary Problems To Fix + +### Public API Imports Core Persistence + +Worst offender: + +```text +api/worker.py + imports core.persistence.context.repository.ContextEventRepository + imports core.persistence.shared.db.get_session +``` + +Why it matters: + +- A worker author importing `Worker` should not load DB/persistence concerns. +- It creates import-cycle risk. +- It makes the public base class responsible for runtime storage. + +Likely fix: + +- Move default output extraction to core. +- Let worker runtime call a core helper after `execute()` finishes. + +### Public API Imports Core Runtime Protocols + +Offender: + +```text +api/evaluation_context.py + imports core.runtime.evaluation.protocols.CriterionRuntime +``` + +Why it matters: + +- Criteria see an internal runtime protocol as a public field. +- It makes the public context harder to document. + +Likely fix: + +- Make runtime private inside context. +- Expose public methods on context. + +### Public API Imports Builtins Registry + +Offender: + +```text +api/worker_spec.py + validate_spec() imports ergon_builtins.registry.WORKERS +``` + +Why it matters: + +- `ergon_core.api` should not know about built-ins. +- Registry validation is runtime/composition behavior. + +Likely fix: + +- Move `WorkerSpec` to core composition. +- Or inject registry validator from core/CLI. + +### Public API Imports Core Generation Types + +Offender: + +```text +api/worker.py + execute() yields core.generation.ContextPartChunk +``` + +Why it matters: + +- Streaming workers are tightly coupled to Ergon's internal transcript/event model. +- If that is intended, it should be explicitly a public advanced type. + +Likely fix: + +- Decide whether to publicize a stable streaming event type. +- Or add a simpler `run()` API and keep streaming advanced. + +## Consolidation Areas + +### Experiment / Definition / Run / Cohort + +Current nouns: + +```text +Experiment +ExperimentRecord +ExperimentDefinition +PersistedExperimentDefinition +RunRecord +ExperimentCohort +ExperimentCohortStats +``` + +Possible clean story: + +```text +Public: + Benchmark + Worker + Rubric + +Application/CLI: + ExperimentSpec or RunSpec + RunHandle + +Core persistence: + ExperimentRecord + ExperimentDefinition + RunRecord + ExperimentCohort +``` + +Open design choice: + +- If users think in experiments, keep `Experiment` public, but make it a pure spec. +- If students mostly write benchmarks/workers/rubrics, hide experiment composition behind CLI commands or a service facade. + +### Evaluator / Rubric / Evaluation Service + +Current nouns: + +```text +Evaluator +Rubric +RubricEvaluationService +TaskEvaluationResult +EvaluationSummary +CriterionResultEntry +``` + +Possible clean story: + +```text +Public: + Criterion + CriterionResult + Rubric + TaskEvaluationResult + +Advanced public: + Evaluator + +Core: + EvaluationRunner + EvaluationSummary + CriterionResultEntry +``` + +Open design choice: + +- Keep `Evaluator` root-exported if dynamic task-specific evaluators are important. +- Otherwise feature `Rubric` and let custom evaluators live in an advanced namespace. + +### Task / Instance / Workflow Graph + +Current nouns: + +```text +BenchmarkTask +instance_key +parent_task_slug +dependency_task_slugs +evaluator_binding_keys +ExperimentDefinitionTask +RunTaskExecution +RunGraphNode +``` + +Possible clean story: + +```text +Public beginner: + Task(slug, description, payload) + +Public advanced: + WorkflowTask(parent, dependencies, evaluator_slots) + +Core: + ExperimentDefinitionTask + RunTaskExecution + RunGraphNode +``` + +Open design choice: + +- Do benchmark authors commonly need dependency graphs? +- If yes, keep the fields but document them as advanced. +- If no, split simple task authoring from graph authoring. + +## Ergonomic API Options + +### Option A: Minimal Authoring Root + +Root exports: + +```python +from ergon_core.api import ( + Benchmark, + BenchmarkTask, + EmptyTaskPayload, + Worker, + WorkerContext, + WorkerOutput, + Criterion, + CriterionResult, + CriterionScoreSpec, + Rubric, + TaskEvaluationResult, + CriteriaCheckError, +) +``` + +Advanced imports: + +```python +from ergon_core.api.advanced import Evaluator, Experiment, WorkerSpec +``` + +Pros: + +- Cleanest beginner story. +- Easy to document. +- Makes runtime/composition concepts visibly advanced. + +Cons: + +- More migration churn. +- Built-in registry typing and core services need import updates. +- Existing code that imports `Experiment` from public API needs shims. + +### Option B: Keep Object-First API, But Purify It + +Root exports still include: + +```python +Experiment +WorkerSpec +Evaluator +``` + +But: + +- `Experiment.persist()` moves to a service. +- `WorkerSpec.validate_spec()` moves to core composition. +- `Worker.get_output()` no longer reads DB from public base class. +- `EvaluationContext.runtime` becomes private helper-backed capability. + +Pros: + +- Less disruptive. +- Preserves object-first feel. +- Keeps `Experiment` available for users who naturally want to compose runs in Python. + +Cons: + +- Beginner docs still need to explain more nouns. +- The top-level API remains larger. +- Harder to communicate what is "normal" vs "advanced". + +### Option C: Two Layer Public API + +Root beginner API: + +```python +Benchmark +Task +Worker +WorkerOutput +Criterion +CriterionResult +Rubric +``` + +Explicit composition API: + +```python +from ergon_core.composition import Experiment, WorkerSpec, persist_experiment +``` + +or: + +```python +from ergon_core.app import define_experiment, run_benchmark +``` + +Pros: + +- Honest separation without hiding useful power. +- CLI and notebook users get a supported high-level entrypoint. +- Students can start with authoring and only learn composition when needed. + +Cons: + +- Requires new package/module naming decisions. +- Need to avoid having too many "public APIs". + +My current recommendation: + +- Option C, implemented gradually. +- Keep compatibility re-exports during migration. +- Document `ergon_core.api` as authoring. +- Add a separate high-level app/composition facade for running things. + +## Proposed Beginner Docs Shape + +### Writing A Benchmark + +```python +from ergon_core.api import Benchmark, BenchmarkTask + +class MyBenchmark(Benchmark): + type_slug = "my-benchmark" + + def build_instances(self): + return { + "default": [ + BenchmarkTask( + task_slug="task-1", + instance_key="default", + description="Solve this problem.", + ) + ] + } +``` + +Possible future version: + +```python +from ergon_core.api import Benchmark, Task + +class MyBenchmark(Benchmark): + type_slug = "my-benchmark" + + def tasks(self): + yield Task("task-1", "Solve this problem.") +``` + +### Writing A Worker + +Current-ish: + +```python +from ergon_core.api import Worker, WorkerContext, BenchmarkTask + +class MyWorker(Worker): + type_slug = "my-worker" + + async def execute(self, task: BenchmarkTask, *, context: WorkerContext): + ... +``` + +Possible future beginner version: + +```python +from ergon_core.api import Worker, WorkerOutput + +class MyWorker(Worker): + type_slug = "my-worker" + + async def run(self, task, context): + return WorkerOutput(output="answer") +``` + +### Writing A Criterion + +Current-ish: + +```python +from ergon_core.api import Criterion, CriterionResult, EvaluationContext + +class MyCriterion(Criterion): + type_slug = "my-criterion" + + async def evaluate(self, context: EvaluationContext): + return CriterionResult( + slug=self.slug, + name=self.slug, + score=1.0, + passed=True, + ) +``` + +Possible helper version: + +```python +return CriterionResult.pass_(self.slug, score=1.0) +``` + +### Writing A Rubric + +```python +from ergon_core.api import Rubric + +rubric = Rubric( + name="default", + criteria=[MyCriterion(slug="correctness")], +) +``` + +## Decisions To Make Together + +### Public Root Exports + +Suggested categories: + +```text +Definitely root public: + Benchmark + BenchmarkTask or Task + EmptyTaskPayload + Worker + WorkerContext + WorkerOutput + Criterion + CriterionResult + CriterionScoreSpec + Rubric + TaskEvaluationResult + CriteriaCheckError + +Maybe root public: + EvaluationContext + Evaluator + BenchmarkDeps + DependencyError + Experiment + +Probably not root public long-term: + WorkerSpec + PersistedExperimentDefinition +``` + +### Concept Names + +Questions: + +- Keep `BenchmarkTask`, or alias it as `Task`? +- Keep `EvaluationContext`, or rename to `CriterionContext`? +- Keep `Evaluator` visible, or make `Rubric` the main public evaluation abstraction? +- Keep `Experiment`, or move composition to a separate facade? +- Rename `PersistedExperimentDefinition` to `WorkflowDefinitionHandle`? +- Rename `RubricEvaluationService` to `EvaluationRunner` or `TaskEvaluationService`? +- Add `CriterionCheckError` alias for `CriteriaCheckError`? + +### Simplicity Targets + +A clean beginner author should not need to know: + +- Inngest, +- database sessions, +- context event persistence, +- run graph node IDs, +- experiment definition row IDs, +- cohort tables, +- telemetry models, +- evaluator binding keys, +- worker binding keys, +- registry validation internals. + +They may need to know: + +- how to create tasks, +- how a worker receives a task, +- how to return an output, +- how criteria inspect the output, +- how a rubric combines criteria. + +## Recommended Refactor Sequence + +### Phase 1: Document And Test The Boundary + +Add tests that encode: + +- `ergon_core.api.worker` must not import DB/session/persistence modules. +- `ergon_core.api.evaluation_context` must not import core runtime protocols directly. +- root exports are intentionally categorized. +- submodule-only public symbols like `CriterionScoreSpec` are either root-exported or documented. + +### Phase 2: Remove Runtime Leakage From Public Worker + +Move from: + +```text +api/worker.py + ContextEventRepository + get_session + AssistantTextPart +``` + +To: + +```text +core/runtime/output_extraction.py + default_worker_output(context) +``` + +Then `worker_execute.py` owns the runtime behavior. + +### Phase 3: Hide Criterion Runtime Behind Public Context Methods + +Move from: + +```text +EvaluationContext.runtime: CriterionRuntime | None +``` + +To: + +```text +EvaluationContext.execute_code(...) +EvaluationContext.read_resource(...) +EvaluationContext.read_resource_by_id(...) +``` + +Internal runtime remains in `core.runtime.evaluation`. + +### Phase 4: Move Composition Plumbing + +Move: + +```text +api/experiment.py -> core/runtime/composition/experiment.py +api/worker_spec.py -> core/runtime/composition/worker_spec.py +api/handles.py -> core/runtime/composition/handles.py +``` + +Keep compatibility shims temporarily: + +```text +api/experiment.py +api/worker_spec.py +api/handles.py +``` + +But update core and CLI imports to the new home first. + +### Phase 5: Add A CLI/Application Facade + +Create something like: + +```text +core/runtime/services/benchmark_run_facade.py +``` + +It owns: + +- build benchmark from slug, +- attach worker/model/rubric, +- persist definition, +- resolve/create cohort, +- create run, +- emit workflow started event, +- poll run status. + +Then `ergon_cli` becomes mostly command parsing and rendering. + +### Phase 6: Consolidate Evaluation Naming + +Decide: + +- root `Rubric` only, or root `Evaluator` too? +- rename internal `RubricEvaluationService`? +- add public helper constructors for result models? +- centralize `CriterionResult` to `EvaluationSummary` conversion. + +## Proposed End State + +```text +ergon_core.api + The authoring kit. + Used by benchmarks, workers, criteria, rubrics, and students. + +ergon_core.core.runtime.composition + Internal composition layer. + Used by CLI and core services to bind benchmarks, workers, rubrics, assignments. + +ergon_core.core.runtime.services + Application services. + Used by API routers and CLI facade. + +ergon_core.core.persistence + SQLModel rows and repositories. + Not imported by public API. + +ergon_cli + Command parsing and display. + Calls a small core facade, not many low-level services. +``` + +## Working Recommendation + +If we want the cleanest ergonomics for students: + +1. Keep the root public API focused on authoring. +2. Keep `Experiment` available for now, but do not teach it first. +3. Move `WorkerSpec` and `PersistedExperimentDefinition` out of the public root over time. +4. Make `Rubric` the public evaluation concept; keep `Evaluator` advanced. +5. Add helper methods/constructors so basic workers and criteria are short to write. +6. Build a separate run/composition facade for CLI and notebook users. + +The practical next conversation should decide three things: + +1. Is `Experiment` a public composition object or a core definition draft? +2. Is worker authoring streaming-first or output-first? +3. Is `Evaluator` a first-class public concept or an advanced escape hatch behind `Rubric`? diff --git a/docs/superpowers/plans/2026-04-28-public-api-folder-plan.md b/docs/superpowers/plans/2026-04-28-public-api-folder-plan.md new file mode 100644 index 00000000..1b7bcb47 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-public-api-folder-plan.md @@ -0,0 +1,413 @@ +# Public API Folder Refactor Plan + +Goal: make `ergon_core.api` small enough for students to understand while moving runtime, persistence, dashboard, cohort, run, and registry plumbing into `ergon_core.core`. + +The public API should be an authoring kit: define benchmarks, tasks, workers, criteria, rubrics, and simple result objects. It should not expose database sessions, persistence handles, Inngest dispatch, cohort management, run lifecycle, or internal evaluation summaries. + +## Proposed Folder Shape + +```text +ergon_core/ + ergon_core/ + api/ + __init__.py + # keep : only the student-facing authoring exports + # export: Benchmark, BenchmarkTask, EmptyTaskPayload + # export: Worker, WorkerContext, WorkerOutput + # export: Criterion, CriterionResult, CriterionScoreSpec + # export: Rubric, TaskEvaluationResult + # export: CriteriaCheckError + # stop exporting: Experiment, WorkerSpec, PersistedExperimentDefinition + # consider hiding: Evaluator, EvaluationContext, BenchmarkDeps, DependencyError + + benchmark.py + # keep : Benchmark as the public dataset/task generator base class + # keep : type_slug, task_payload_model, build_instances() + # keep : parse_task_payload() + # simplify : evaluator_requirements() should become optional/advanced + # move : dependency package checking to core/runtime/dependencies.py adapter + # merge : onboarding_deps and required_packages into one simpler authoring metadata story + + task_types.py + # keep : BenchmarkTask and EmptyTaskPayload + # consider rename later : BenchmarkTask -> Task or TaskSpec + # keep public because benchmarks, workers, and criteria all share it + # do not expose: ExperimentDefinitionTask persistence model here + + worker.py + # keep : Worker ABC and execute(task, context=...) + # keep : optional from_buffer() only if resumption remains an author-facing extension point + # move : default DB-backed get_output() implementation to core/runtime/output_extraction.py + # move : ContextEventRepository/get_session imports out of public API + # move : AssistantTextPart/ContextPartChunk dependency behind a smaller public streaming type or an advanced namespace + # simplify : base Worker should not know how context events are persisted + + worker_context.py + # keep : WorkerContext as the minimal execution context passed to Worker.execute() + # simplify : expose only run_id, task_id, execution_id, sandbox_id, metadata if possible + # move inward : definition_id and node_id if only runtime/delegation needs them + # consider : a separate internal CoreWorkerContext for graph/runtime identity + + results.py + # keep : WorkerOutput + # keep : CriterionScoreSpec + # keep : CriterionResult + # keep : TaskEvaluationResult + # keep or move advanced : CriterionObservation and CriterionObservationMessage + # move : JsonObject import from core into a public local alias/type + # merge : align CriterionResult fields with core EvaluationSummary conversion in one adapter + + criterion.py + # keep : Criterion ABC + # keep : evaluate(context) -> CriterionResult + # move : dependency package checking to core validation helper + # simplify : criterion authors should not need to import core runtime protocols + + evaluation_context.py + # keep temporarily : EvaluationContext for compatibility + # replace with : CriterionContext or EvaluationContext with public helper methods + # move : CriterionRuntime Protocol import to core/runtime/evaluation/protocols.py only + # hide : sandbox manager/runtime internals behind context.execute_code(), context.read_resource(), etc. + # eventual delete : if Criterion can receive a simpler public CriterionContext + + evaluator.py + # keep : Rubric as the common public evaluation concept + # consider advanced : Evaluator ABC moves to api/advanced/evaluator.py or core/runtime/evaluation + # merge : default weighted aggregation remains Rubric + # move : dynamic evaluator orchestration details to core/runtime/services/rubric_evaluation_service.py + # clarify : Rubric = author-facing grouping of criteria; evaluator service = internal runner + + errors.py + # keep : CriteriaCheckError + # consider move : DependencyError to core/runtime/dependencies.py unless public callers catch it + + benchmark_deps.py + # merge : into Benchmark metadata or move to api/onboarding.py + # keep temporarily : compatibility for ergon_cli/onboarding/profile.py and built-in benchmark declarations + # eventual delete : once onboarding reads a simpler Benchmark.onboarding field + + experiment.py + # move to core/runtime/composition/experiment.py or core/runtime/services/experiment_composition.py + # reason : binds benchmark + worker specs + evaluators + assignments for persistence + # reason : persist() calls core ExperimentPersistenceService + # public replacement : a simple CLI/application facade, not a student authoring primitive + # eventual delete from top-level api + + worker_spec.py + # move to core/runtime/composition/worker_spec.py + # reason : config-time descriptor for registry lookup, not worker authoring + # reason : validate_spec() imports ergon_builtins.registry.WORKERS + # public replacement : CLI accepts worker_slug/model and core builds WorkerSpec internally + # eventual delete from top-level api + + handles.py + # move to core/runtime/services/experiment_handles.py or core/runtime/composition/handles.py + # reason : PersistedExperimentDefinition is a persistence/run launch handle + # public replacement : CLI-facing RunHandle/DefinitionHandle returned by core facade + # eventual delete from top-level api +``` + +```text +ergon_core/ + ergon_core/ + core/ + runtime/ + composition/ + __init__.py + # create : internal composition exports for CLI/core + + experiment.py + # move from api/experiment.py + # keep : Experiment composition root if core still needs object-first persistence + # change : persist() should become service-owned, not a method on Experiment + + worker_spec.py + # move from api/worker_spec.py + # keep : WorkerSpec registry descriptor + # keep : validate_spec() registry lookup here, away from public API + + handles.py + # move from api/handles.py + # keep : PersistedExperimentDefinition or rename to WorkflowDefinitionHandle + + output_extraction.py + # create : default worker output extraction from context events + # move from api/worker.py : ContextEventRepository/get_session/AssistantTextPart logic + # used by : core/runtime/inngest/worker_execute.py + + dependencies.py + # keep : check_packages() + # add : validate_component_dependencies(component_type, slug, packages, install_hint) + # public ABCs call this only through small wrappers, or core validates before launch + + evaluation/ + protocols.py + # keep : CriterionRuntime internal protocol + # no public api imports should depend on this directly + + context.py + # create or rename : internal TaskEvaluationContext/CriterionContext live here + # owns : sandbox/runtime details for criterion execution + + adapters.py + # create : convert public CriterionResult into persisted EvaluationSummary entries + # merge logic currently split between public results and persistence summary models + + evaluation_schemas.py + # keep : internal CriterionSpec, TaskEvaluationContext, CriterionContext + # maybe rename : criterion_specs.py if it remains evaluation-engine only + + services/ + public_api_facade.py + # create : CLI/application facade for common operations + # owns : define benchmark experiment, persist definition, create cohort/run, dispatch, poll + # goal : CLI should import one core facade instead of many core services/models + + experiment_persistence_service.py + # keep : writes Experiment/BenchmarkTask object graph to immutable definition rows + # adjust imports : read Experiment and WorkerSpec from core/runtime/composition + + experiment_definition_service.py + # keep : create ExperimentRecord sample selections + # clarify name : this creates experiment records, not immutable workflow definitions + # possible rename later : benchmark_experiment_service.py + + experiment_launch_service.py + # keep : materializes runs for defined ExperimentRecord rows + # adjust imports : use core composition types, not public api Experiment/WorkerSpec + + rubric_evaluation_service.py + # keep : internal service runner + # clarify : not the same concept as public Rubric + # maybe rename : task_evaluation_service.py + + evaluation_persistence_service.py + # keep : persistence of evaluation summaries + # move conversion from public-ish result shapes into runtime/evaluation/adapters.py + + cohort_service.py + # keep : cohorts are operator/runtime grouping, not student API + # expose via facade only for CLI/dashboard + + run_service.py + # keep : runs are runtime telemetry/lifecycle, not student API + # expose via facade only for CLI/dashboard +``` + +```text +ergon_cli/ + ergon_cli/ + composition/ + __init__.py + # delete or shrink substantially + # current : imports public Experiment + WorkerSpec + # move : build_experiment() logic to core/runtime/composition or services/public_api_facade.py + # replacement : CLI passes slugs/options to core facade + + commands/ + benchmark.py + # keep : command parsing and rendering only + # move inward : create_run, WorkflowStartedEvent, inngest_client, RunRecord polling + # replace with : public_api_facade.run_benchmark(...) + # keep : setup benchmark E2B template logic unless moved to onboarding service + + experiment.py + # keep : command parsing/rendering + # replace multiple core service imports with one facade import + + run.py + # keep : command parsing/rendering + # replace direct RunRecord/run_service access with one run facade + + workflow.py + # keep : command parsing/rendering + # replace direct workflow_service/db access with facade if possible + + onboarding/ + profile.py + # keep : onboarding profile behavior + # change later : read Benchmark.onboarding metadata instead of BenchmarkDeps directly +``` + +```text +ergon_builtins/ + ergon_builtins/ + benchmarks/ + */benchmark.py + # keep public imports : Benchmark, BenchmarkTask, EmptyTaskPayload + # update : BenchmarkDeps if moved/merged + # no direct dependency on core persistence or run concepts + + */rubric.py + # keep public imports : Rubric, CriterionResult, TaskEvaluationResult, BenchmarkTask + # if Evaluator moves advanced/internal, custom rubrics should still subclass Rubric + + */criterion.py + # keep public imports : Criterion, CriterionResult, CriterionScoreSpec + # update : EvaluationContext -> simpler CriterionContext if introduced + + workers/ + */*.py + # keep public imports : Worker, WorkerContext, WorkerOutput, BenchmarkTask + # update : streaming chunk type if ContextPartChunk is hidden or rehomed + + registry.py + # keep : plugin registry for built-ins + # core composition validates WorkerSpec/Benchmark/Evaluator slugs against this + # public API should not import this registry directly +``` + +## Concept Merges And Renames + +### Experiment Concepts + +Current concepts: + +- `api.Experiment`: object graph for benchmark + workers + evaluators + assignments. +- `core.persistence.telemetry.ExperimentRecord`: cohort/sample-selection record. +- `core.persistence.definitions.ExperimentDefinition`: immutable workflow definition rows. + +Plan: + +- Keep `ExperimentDefinition` as a core persistence name. +- Consider renaming `ExperimentRecord` service language to `BenchmarkExperiment` or `ExperimentPlan` later, because it is not the immutable workflow definition. +- Move public `Experiment` into core composition, or rename it `WorkflowDefinitionDraft` if it remains object-first. +- Do not ask students to learn all three names. + +### Worker Concepts + +Current concepts: + +- `Worker`: execution-ready authoring base class. +- `WorkerSpec`: config-time registry descriptor. +- `ExperimentDefinitionWorker`: persisted worker binding row. + +Plan: + +- Keep `Worker` public. +- Move `WorkerSpec` into core composition. +- Keep `ExperimentDefinitionWorker` internal. +- CLI should accept `worker_slug` and `model`; core creates `WorkerSpec`. + +### Evaluation Concepts + +Current concepts: + +- `Criterion`: atomic authoring unit. +- `Rubric`: fixed-list `Evaluator` with aggregation. +- `Evaluator`: abstract dynamic evaluator. +- `RubricEvaluationService`: runtime service that executes criteria and aggregates. +- `CriterionResultEntry` / `EvaluationSummary`: persisted dashboard schema. + +Plan: + +- Keep `Criterion` and `Rubric` public. +- Keep `Evaluator` advanced or internal unless third-party dynamic evaluators are required. +- Rename or document `RubricEvaluationService` as internal task evaluation runner. +- Keep `EvaluationSummary` internal. +- Add one adapter that maps `CriterionResult`/`TaskEvaluationResult` to persisted summary rows. + +### Task Concepts + +Current concepts: + +- `BenchmarkTask`: author-facing task object generated by a benchmark. +- `ExperimentDefinitionTask`: persisted definition row. +- `RunTaskExecution`: runtime execution telemetry row. + +Plan: + +- Keep `BenchmarkTask` public for now. +- Consider future alias `Task = BenchmarkTask` for student docs. +- Keep persistence/runtime task rows internal. +- Core adapters convert public task specs into definition rows. + +### Cohort And Run Concepts + +Current concepts: + +- Cohorts and runs are not in `ergon_core.api`, but CLI imports core services/models directly. +- `ExperimentCohort`, `ExperimentCohortStats`, `RunRecord`, `RunTaskExecution`, `RunTaskEvaluation` are operator/runtime concepts. + +Plan: + +- Keep cohorts and runs out of the student authoring API. +- Add a CLI/application facade so built-in CLI can use cohorts/runs without importing persistence models, Inngest events, or low-level services. +- Dashboard/API routers can still use detailed core services and DTOs. + +## Compatibility Strategy + +1. Add architecture tests for the intended boundary before moving code. +2. Keep compatibility re-exports for one refactor window: + - `ergon_core.api.experiment.Experiment` + - `ergon_core.api.worker_spec.WorkerSpec` + - `ergon_core.api.handles.PersistedExperimentDefinition` + - `ergon_core.api.benchmark_deps.BenchmarkDeps` +3. Update `ergon_cli` and `ergon_core.core` imports first so internal code no longer depends on public API for internal composition. +4. Update `ergon_builtins` imports only after the public authoring surface is stable. +5. Remove compatibility shims once tests and docs no longer reference moved symbols. + +## Suggested Implementation Order + +```text +phase_1_boundary_tests/ + tests/unit/architecture/test_public_api_boundaries.py + # add forbidden import checks for api -> core.persistence, core.runtime.evaluation.protocols, core.generation + # add explicit expected top-level public exports + +phase_2_worker_runtime_split/ + ergon_core/ergon_core/api/worker.py + # keep Worker ABC only + # remove DB/context event imports + + ergon_core/ergon_core/core/runtime/output_extraction.py + # create default output extraction helper + + ergon_core/ergon_core/core/runtime/inngest/worker_execute.py + # use output_extraction helper after worker.execute() + +phase_3_composition_move/ + ergon_core/ergon_core/core/runtime/composition/ + # create experiment.py, worker_spec.py, handles.py + + ergon_core/ergon_core/api/ + # leave temporary import shims for Experiment, WorkerSpec, PersistedExperimentDefinition + + ergon_cli/ergon_cli/composition/__init__.py + # migrate logic or shrink to facade call + +phase_4_cli_facade/ + ergon_core/ergon_core/core/runtime/services/public_api_facade.py + # create stable CLI-facing functions/classes + + ergon_cli/ergon_cli/commands/*.py + # replace direct core service/model/event imports where practical + +phase_5_evaluation_simplification/ + ergon_core/ergon_core/api/evaluation_context.py + # replace raw runtime protocol exposure with public context methods + + ergon_core/ergon_core/core/runtime/evaluation/adapters.py + # centralize result-to-summary conversion + + ergon_core/ergon_core/api/evaluator.py + # make Rubric primary; move Evaluator to advanced/internal if desired + +phase_6_cleanup/ + ergon_core/ergon_core/api/__init__.py + # remove moved concepts from top-level exports + + docs/ + # update student-facing examples to import only the authoring kit +``` + +## Desired Final Student-Facing Mental Model + +```text +I define a Benchmark. +The Benchmark returns Tasks. +A Worker solves each Task. +A Criterion checks the output. +A Rubric combines Criteria into a score. +Ergon core handles experiments, definitions, cohorts, runs, persistence, dispatch, and dashboards. +``` diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/criteria.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/criteria.py index e82fa894..17f8d7e7 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/criteria.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/criteria.py @@ -21,7 +21,7 @@ def make_code_check( ) -> CodeCheckCriterion: """Create a GDP code-check criterion.""" return CodeCheckCriterion( - name=name, + slug=name, code_template=code_template, description=description, weight=weight, @@ -40,7 +40,7 @@ def make_llm_judge( ) -> LLMJudgeCriterion: """Create a GDP LLM-judge criterion.""" return LLMJudgeCriterion( - name=name, + slug=name, prompt_template=prompt_template, description=description, weight=weight, diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/criteria.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/criteria.py index 5c47f4f2..9f376af4 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/criteria.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/criteria.py @@ -27,7 +27,7 @@ def build_proof_criterion( Ground-truth proof text (for reference only, not used in grading). """ return ProofVerificationCriterion( - name="proof_verification", + slug="proof_verification", weight=1.0, max_score=max_score, problem_statement=problem_statement, diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py index 8596ef26..706697bc 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py @@ -52,7 +52,7 @@ class ProofVerificationCriterion(Criterion): def __init__( self, *, - name: str = "proof_verification", + slug: str = "proof_verification", weight: float = 1.0, max_score: float = 1.0, problem_statement: str | None = None, @@ -60,7 +60,7 @@ def __init__( formal_system: str = "lean", ) -> None: super().__init__( - name=name, + slug=slug, weight=weight, score_spec=CriterionScoreSpec(max_score=max_score), ) @@ -73,7 +73,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: if proof_data is None: return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=0.0, passed=False, weight=self.weight, @@ -103,7 +103,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=score, passed=outcome.verified, weight=self.weight, diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py index 602b6d0c..4075ef84 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py @@ -68,7 +68,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: ] return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=self.score_spec.max_score if verdict.passed else 0.0, passed=verdict.passed, weight=self.weight, diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py index 273a8ddd..d0075dcc 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py @@ -127,17 +127,17 @@ class SWEBenchTestCriterion(Criterion): def __init__( self, *, - name: str = "swebench-test-resolution", + slug: str = "swebench-test-resolution", weight: float = 1.0, ) -> None: - super().__init__(name=name, weight=weight) + super().__init__(slug=slug, weight=weight) async def evaluate(self, context: EvaluationContext) -> CriterionResult: patch_text = await _extract_patch_via_runtime(context) if not patch_text.strip(): return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=0.0, passed=False, weight=self.weight, @@ -179,7 +179,7 @@ async def _run_and_grade( if r.exit_code != 0: detail = r.stdout if r.stdout is not None else r.stderr return _error_result( - self.name, + self.slug, self.weight, "install_repo failed", # reason: both CommandResult fields are `str | None`, but @@ -196,7 +196,7 @@ async def _run_and_grade( await _write_and_apply(runtime, "/tmp/test.patch", test_patch) await _write_and_apply(runtime, "/tmp/agent.patch", patch_text) except RuntimeError as exc: - return _error_result(self.name, self.weight, "git apply failed", str(exc)) + return _error_result(self.slug, self.weight, "git apply failed", str(exc)) # 3. Run eval script with stderr merged so the log has everything. r = await runtime.run_command( @@ -216,7 +216,7 @@ async def _run_and_grade( resolved = bool(entry.get("resolved")) return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=1.0 if resolved else 0.0, passed=resolved, weight=self.weight, @@ -250,10 +250,10 @@ async def _write_and_apply( raise RuntimeError(f"git apply {path} failed: {stdout[-800:]}") -def _error_result(name: str, weight: float, kind: str, detail: str) -> CriterionResult: +def _error_result(slug: str, weight: float, kind: str, detail: str) -> CriterionResult: return CriterionResult( - slug=name, - name=name, + slug=slug, + name=slug, score=0.0, passed=False, weight=weight, diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py b/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py index 61f71ffd..35ca6d26 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py @@ -26,15 +26,15 @@ class CodeCheckCriterion(Criterion): def __init__( self, *, - name: str, + slug: str, code_template: str, description: str = "", # slopcop: ignore[no-str-empty-default] weight: float = 1.0, max_score: float = 1.0, ) -> None: super().__init__( - name=name, - description=description or name, + slug=slug, + description=description or slug, weight=weight, score_spec=CriterionScoreSpec(max_score=max_score), ) @@ -46,10 +46,10 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: score = self.score_spec.max_score if passed else 0.0 return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=score, passed=passed, weight=self.weight, max_score=self.score_spec.max_score, - feedback=f"Code check '{self.name}': {'passed' if passed else 'failed'}", + feedback=f"Code check '{self.slug}': {'passed' if passed else 'failed'}", ) diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py index 046025b2..ee7b8243 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py @@ -38,7 +38,7 @@ class LLMJudgeCriterion(Criterion): def __init__( self, *, - name: str, + slug: str, prompt_template: str, description: str = "", # slopcop: ignore[no-str-empty-default] weight: float = 1.0, @@ -46,8 +46,8 @@ def __init__( model: str = "gpt-4o", ) -> None: super().__init__( - name=name, - description=description or name, + slug=slug, + description=description or slug, weight=weight, score_spec=CriterionScoreSpec(max_score=max_score), ) @@ -75,7 +75,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: score = self.score_spec.max_score if verdict.passed else 0.0 return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=score, passed=verdict.passed, weight=self.weight, diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py b/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py index 659fe43c..be5ac0ee 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py @@ -16,12 +16,12 @@ class SandboxFileCheckCriterion(Criterion): def __init__( self, *, - name: str = "sandbox-file-check", + slug: str = "sandbox-file-check", weight: float = 1.0, expected_path: str = MARKER_PATH, expected_content: str = MARKER_CONTENT, ) -> None: - super().__init__(name=name, weight=weight) + super().__init__(slug=slug, weight=weight) self.expected_path = expected_path self.expected_content = expected_content @@ -29,7 +29,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: if not context.sandbox_id: return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=0.0, passed=False, weight=self.weight, @@ -42,7 +42,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: except ImportError: return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=0.0, passed=False, weight=self.weight, @@ -59,7 +59,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: found = self.expected_content in content return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=1.0 if found else 0.0, passed=found, weight=self.weight, @@ -73,7 +73,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: except Exception as exc: # slopcop: ignore[no-broad-except] return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=0.0, passed=False, weight=self.weight, diff --git a/ergon_builtins/ergon_builtins/evaluators/rubrics/swebench_rubric.py b/ergon_builtins/ergon_builtins/evaluators/rubrics/swebench_rubric.py index 0bb6bfc9..19c952f5 100644 --- a/ergon_builtins/ergon_builtins/evaluators/rubrics/swebench_rubric.py +++ b/ergon_builtins/ergon_builtins/evaluators/rubrics/swebench_rubric.py @@ -17,5 +17,5 @@ class SWEBenchRubric(Rubric): def __init__(self, *, name: str = "swebench-rubric") -> None: super().__init__( name=name, - criteria=[SWEBenchTestCriterion(name="test-resolution", weight=1.0)], + criteria=[SWEBenchTestCriterion(slug="test-resolution", weight=1.0)], ) diff --git a/ergon_core/ergon_core/api/criterion.py b/ergon_core/ergon_core/api/criterion.py index 5724366f..46e6268a 100644 --- a/ergon_core/ergon_core/api/criterion.py +++ b/ergon_core/ergon_core/api/criterion.py @@ -23,29 +23,18 @@ class Criterion(ABC): def __init__( self, *, - slug: str | None = None, - name: str | None = None, + slug: str, description: str | None = None, weight: float = 1.0, score_spec: CriterionScoreSpec | None = None, metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] ) -> None: - resolved_slug = slug or name - if resolved_slug is None: - raise ValueError("Criterion requires a slug") - self.slug = resolved_slug - # Compatibility alias for older criteria/tests while callers migrate. - self.name = resolved_slug - self.description = description or resolved_slug + self.slug = slug + self.description = description or slug self.weight = weight self.score_spec = score_spec or CriterionScoreSpec() self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] - @property - def max_score(self) -> float: - """Compatibility alias for the criterion-local score ceiling.""" - return self.score_spec.max_score - @abstractmethod async def evaluate( self, diff --git a/ergon_core/ergon_core/api/handles.py b/ergon_core/ergon_core/api/handles.py index ff57042f..5cc8a931 100644 --- a/ergon_core/ergon_core/api/handles.py +++ b/ergon_core/ergon_core/api/handles.py @@ -1,4 +1,4 @@ -"""Public lifecycle handle types returned by persist() and run().""" +"""Public lifecycle handle types returned by Experiment.persist().""" from datetime import datetime from typing import Any diff --git a/ergon_core/ergon_core/core/api/app.py b/ergon_core/ergon_core/core/api/app.py index 721a8155..662b39a8 100644 --- a/ergon_core/ergon_core/core/api/app.py +++ b/ergon_core/ergon_core/core/api/app.py @@ -4,6 +4,7 @@ import os import sys from contextlib import asynccontextmanager +from importlib import import_module # Root-logger handler so ``logger.exception`` / ``logger.error`` from # anywhere in the app actually reach ``docker compose logs api``. @@ -22,12 +23,10 @@ import inngest.fast_api from ergon_core.core.api.cohorts import router as cohorts_router from ergon_core.core.api.experiments import router as experiments_router -from ergon_core.core.api.rollouts import init_service as init_rollout_service from ergon_core.core.api.rollouts import router as rollouts_router from ergon_core.core.api.runs import router as runs_router -from ergon_core.core.api.startup_plugins import run_startup_plugins from ergon_core.core.api.test_harness import router as _test_harness_router -from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.dashboard.provider import init_dashboard_emitter, reset_dashboard_emitter from ergon_core.core.persistence.shared.db import ensure_db, get_session from ergon_core.core.sandbox.event_sink import ( CompoundSandboxEventSink, @@ -44,19 +43,32 @@ logger = logging.getLogger(__name__) +def _run_startup_plugins(plugin_specs: tuple[str, ...]) -> None: + for spec in plugin_specs: + module_name, sep, attr_name = spec.partition(":") + if not sep or not module_name or not attr_name: + raise RuntimeError( + f"Invalid ERGON_STARTUP_PLUGINS entry {spec!r}; expected 'module:function'" + ) + module = import_module(module_name) + plugin = getattr(module, attr_name) # slopcop: ignore[no-hasattr-getattr] + plugin() + + @asynccontextmanager async def lifespan(app: FastAPI): logger.info("starting ensure_db...") ensure_db() logger.info("ensure_db done, initializing RolloutService...") settings = Settings() - init_rollout_service( - RolloutService( - session_factory=get_session, - inngest_send=inngest_client.send_sync, - tokenizer_name=settings.default_tokenizer, - ) + app.state.rollout_service = RolloutService( + session_factory=get_session, + inngest_send=inngest_client.send_sync, + tokenizer_name=settings.default_tokenizer, ) + app.state.vllm_manager = None + dashboard_emitter = init_dashboard_emitter(enabled=True) + app.state.dashboard_emitter = dashboard_emitter # Wire the dashboard event sink on every sandbox manager subclass. # Import ergon_builtins here (deferred) to avoid a circular import at @@ -74,9 +86,13 @@ async def lifespan(app: FastAPI): "sandbox event sink wired on %d manager subclass(es)", 1 + len(SANDBOX_MANAGERS), ) + _run_startup_plugins(settings.startup_plugins) logger.info("app startup complete — all subsystems initialised") - yield + try: + yield + finally: + reset_dashboard_emitter() app = FastAPI( @@ -101,6 +117,4 @@ def health() -> dict[str, str]: if settings.enable_test_harness: app.include_router(_test_harness_router) -run_startup_plugins(settings.startup_plugins) - inngest.fast_api.serve(app, inngest_client, ALL_FUNCTIONS) diff --git a/ergon_core/ergon_core/core/api/rollouts.py b/ergon_core/ergon_core/core/api/rollouts.py index c95f9487..741a0426 100644 --- a/ergon_core/ergon_core/core/api/rollouts.py +++ b/ergon_core/ergon_core/core/api/rollouts.py @@ -6,6 +6,7 @@ """ import logging +from typing import Annotated, cast from uuid import UUID from ergon_core.core.rl.rollout_service import RolloutService @@ -17,67 +18,71 @@ WeightSyncResponse, ) from ergon_core.core.rl.vllm_manager import VLLMManager -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request logger = logging.getLogger(__name__) router = APIRouter(prefix="/rollouts", tags=["rollouts"]) -_service: RolloutService | None = None -_vllm_manager: VLLMManager | None = None +def get_rollout_service(request: Request) -> RolloutService: + service = getattr(request.app.state, "rollout_service", None) + if service is None: + raise HTTPException(503, "RolloutService not initialized") + return cast(RolloutService, service) -def init_service( - service: RolloutService, - vllm_manager: VLLMManager | None = None, -) -> None: - """Called during app lifespan to set singletons.""" - global _service, _vllm_manager - _service = service - _vllm_manager = vllm_manager - -def _get_service() -> RolloutService: - if _service is None: - raise HTTPException(503, "RolloutService not initialized") - return _service +def get_vllm_manager(request: Request) -> VLLMManager | None: + return cast(VLLMManager | None, getattr(request.app.state, "vllm_manager", None)) @router.post("/submit", response_model=SubmitResponse, status_code=202) -def submit_rollout(request: SubmitRequest) -> SubmitResponse: +def submit_rollout( + request: SubmitRequest, + service: Annotated[RolloutService, Depends(get_rollout_service)], +) -> SubmitResponse: """Start a batch of episodes. Returns immediately with batch_id.""" - return _get_service().submit(request) + return service.submit(request) @router.get("/{batch_id}", response_model=PollResponse) -def poll_rollout(batch_id: UUID) -> PollResponse: +def poll_rollout( + batch_id: UUID, + service: Annotated[RolloutService, Depends(get_rollout_service)], +) -> PollResponse: """Poll batch status. Returns trajectories when complete.""" - result = _get_service().poll(batch_id) + result = service.poll(batch_id) if result is None: raise HTTPException(404, f"Batch {batch_id} not found") return result @router.delete("/{batch_id}", status_code=204) -def cancel_rollout(batch_id: UUID) -> None: +def cancel_rollout( + batch_id: UUID, + service: Annotated[RolloutService, Depends(get_rollout_service)], +) -> None: """Cancel a pending/running batch.""" - _get_service().cancel(batch_id) + service.cancel(batch_id) @router.post("/sync-weights", response_model=WeightSyncResponse) -def sync_weights(request: WeightSyncRequest) -> WeightSyncResponse: +def sync_weights( + request: WeightSyncRequest, + vllm_manager: Annotated[VLLMManager | None, Depends(get_vllm_manager)], +) -> WeightSyncResponse: """Restart vLLM with a new checkpoint (full-weight RFT). Blocks until the new vLLM process is healthy. """ - if _vllm_manager is None: + if vllm_manager is None: raise HTTPException( 501, "vLLM manager not configured. Set ERGON_VLLM_ENABLED=true " "to let Ergon manage a vLLM process.", ) try: - _vllm_manager.restart(request.checkpoint_path) + vllm_manager.restart(request.checkpoint_path) except (RuntimeError, TimeoutError) as exc: logger.error("Weight sync failed: %s", exc) raise HTTPException(500, str(exc)) from exc diff --git a/ergon_core/ergon_core/core/api/startup_plugins.py b/ergon_core/ergon_core/core/api/startup_plugins.py deleted file mode 100644 index c61c03fd..00000000 --- a/ergon_core/ergon_core/core/api/startup_plugins.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Optional startup plugin loader.""" - -from importlib import import_module - - -def run_startup_plugins(plugin_specs: tuple[str, ...]) -> None: - for spec in plugin_specs: - module_name, sep, attr_name = spec.partition(":") - if not sep or not module_name or not attr_name: - raise RuntimeError( - f"Invalid ERGON_STARTUP_PLUGINS entry {spec!r}; expected 'module:function'" - ) - module = import_module(module_name) - plugin = getattr(module, attr_name) # slopcop: ignore[no-hasattr-getattr] - plugin() diff --git a/ergon_core/ergon_core/core/dashboard/__init__.py b/ergon_core/ergon_core/core/dashboard/__init__.py index 51f74967..33db25e9 100644 --- a/ergon_core/ergon_core/core/dashboard/__init__.py +++ b/ergon_core/ergon_core/core/dashboard/__init__.py @@ -2,7 +2,6 @@ from ergon_core.core.dashboard.emitter import ( DashboardEmitter, - dashboard_emitter, emit_cohort_updated_for_run, ) from ergon_core.core.dashboard.event_contracts import ( @@ -18,6 +17,12 @@ DashboardWorkflowStartedEvent, TaskTreeNode, ) +from ergon_core.core.dashboard.provider import ( + get_dashboard_emitter, + init_dashboard_emitter, + reset_dashboard_emitter, + set_dashboard_emitter, +) __all__ = [ "CohortUpdatedEvent", @@ -32,6 +37,9 @@ "DashboardWorkflowCompletedEvent", "DashboardWorkflowStartedEvent", "TaskTreeNode", - "dashboard_emitter", "emit_cohort_updated_for_run", + "get_dashboard_emitter", + "init_dashboard_emitter", + "reset_dashboard_emitter", + "set_dashboard_emitter", ] diff --git a/ergon_core/ergon_core/core/dashboard/emitter.py b/ergon_core/ergon_core/core/dashboard/emitter.py index 182cc291..40456c3d 100644 --- a/ergon_core/ergon_core/core/dashboard/emitter.py +++ b/ergon_core/ergon_core/core/dashboard/emitter.py @@ -449,10 +449,6 @@ async def cohort_updated( except Exception: # slopcop: ignore[no-broad-except] logger.warning("Failed to emit dashboard/cohort.updated", exc_info=True) - -dashboard_emitter = DashboardEmitter(enabled=True) - - async def emit_cohort_updated_for_run(run_id: UUID) -> None: """Refresh and emit the current cohort summary for a run, if it has a cohort.""" cohort_id = queries.runs.get_cohort_id(run_id) @@ -463,7 +459,9 @@ async def emit_cohort_updated_for_run(run_id: UUID) -> None: summary = experiment_cohort_service.get_summary(cohort_id) if summary is None: return - await dashboard_emitter.cohort_updated( + from ergon_core.core.dashboard.provider import get_dashboard_emitter + + await get_dashboard_emitter().cohort_updated( cohort_id=summary.cohort_id, summary=summary, ) diff --git a/ergon_core/ergon_core/core/dashboard/provider.py b/ergon_core/ergon_core/core/dashboard/provider.py new file mode 100644 index 00000000..eadc1090 --- /dev/null +++ b/ergon_core/ergon_core/core/dashboard/provider.py @@ -0,0 +1,34 @@ +"""Process-level DashboardEmitter provider. + +FastAPI lifespan owns construction. Runtime code that is not running inside a +request can retrieve the initialized process instance from here. +""" + +from ergon_core.core.dashboard.emitter import DashboardEmitter + +_dashboard_emitter: DashboardEmitter | None = None + + +def init_dashboard_emitter(*, enabled: bool = True) -> DashboardEmitter: + """Create and install the process DashboardEmitter instance.""" + return set_dashboard_emitter(DashboardEmitter(enabled=enabled)) + + +def set_dashboard_emitter(emitter: DashboardEmitter) -> DashboardEmitter: + """Install an already-created DashboardEmitter instance.""" + global _dashboard_emitter + _dashboard_emitter = emitter + return _dashboard_emitter + + +def get_dashboard_emitter() -> DashboardEmitter: + """Return the process DashboardEmitter, requiring startup initialization.""" + if _dashboard_emitter is None: + raise RuntimeError("DashboardEmitter has not been initialized") + return _dashboard_emitter + + +def reset_dashboard_emitter() -> None: + """Clear the process DashboardEmitter instance.""" + global _dashboard_emitter + _dashboard_emitter = None diff --git a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py b/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py index 793bd81b..27f7c8ae 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py +++ b/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py @@ -9,7 +9,7 @@ import logging import inngest -from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.dashboard.provider import get_dashboard_emitter from ergon_core.core.json_types import JsonObject from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.runtime.events.task_events import TaskCancelledEvent @@ -59,6 +59,6 @@ def _update_db_rows() -> JsonObject: cleanup_result = await ctx.step.run("update-db-rows", _update_db_rows) - await dashboard_emitter.task_cancelled(payload) + await get_dashboard_emitter().task_cancelled(payload) return cleanup_result diff --git a/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py b/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py index 21bce54d..531d3085 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py +++ b/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py @@ -5,7 +5,7 @@ import inngest from ergon_core.core.dashboard import emit_cohort_updated_for_run -from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import ExperimentRecord, RunRecord from ergon_core.core.runtime.events.infrastructure_events import RunCleanupEvent @@ -55,7 +55,7 @@ async def complete_workflow_fn(ctx: inngest.Context) -> WorkflowCompleteResult: if _run and _run.started_at and _run.completed_at else 0.0 ) - await dashboard_emitter.workflow_completed( + await get_dashboard_emitter().workflow_completed( run_id=payload.run_id, status="completed", duration_seconds=_duration, diff --git a/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py b/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py index aa823f13..43cf0869 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py +++ b/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py @@ -10,7 +10,7 @@ import inngest from ergon_builtins.registry import BENCHMARKS, EVALUATORS, SANDBOX_MANAGERS from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.queries import queries from ergon_core.core.sandbox.manager import DefaultSandboxManager from ergon_core.core.runtime.errors import ContractViolationError, RegistryLookupError @@ -161,7 +161,7 @@ async def evaluate_task_run(ctx: inngest.Context) -> EvaluateTaskRunResult: evaluator_id=evaluator_id, service_result=service_result, ) - await dashboard_emitter.task_evaluation_updated( + await get_dashboard_emitter().task_evaluation_updated( run_id=run_id, task_id=node_id, evaluation=persisted.dashboard_dto, diff --git a/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py b/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py index 4d0650e9..1969ab5c 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py +++ b/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py @@ -5,8 +5,8 @@ from uuid import UUID, uuid4, uuid5 import inngest -from ergon_core.core.dashboard.emitter import dashboard_emitter from ergon_core.core.dashboard.event_contracts import TaskTreeNode, WorkerRef +from ergon_core.core.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.definitions.models import ExperimentDefinitionWorker from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode from ergon_core.core.persistence.shared.db import get_session @@ -180,7 +180,7 @@ async def start_workflow_fn(ctx: inngest.Context) -> WorkflowStartResult: task_tree = _build_task_tree_for_run(payload.run_id, payload.definition_id) - await dashboard_emitter.workflow_started( + await get_dashboard_emitter().workflow_started( run_id=payload.run_id, experiment_id=payload.definition_id, workflow_name=initialized.benchmark_type, diff --git a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py index a9d4e5d8..58a2edfe 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py +++ b/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py @@ -12,10 +12,9 @@ import inngest from ergon_builtins.registry import BENCHMARKS, WORKERS -from ergon_core.api.results import WorkerOutput from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload from ergon_core.api.worker_context import WorkerContext -from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.dashboard.provider import get_dashboard_emitter from ergon_core.core.generation import ContextPartChunk from ergon_core.core.persistence.context.repository import ContextEventRepository from ergon_core.core.persistence.queries import queries @@ -34,14 +33,6 @@ logger = logging.getLogger(__name__) -def _worker_execute_result_from_output(output: WorkerOutput) -> WorkerExecuteResult: - return WorkerExecuteResult( - success=output.success, - final_assistant_message=output.output, - error=None if output.success else output.output, - ) - - @inngest_client.create_function( fn_id="worker-execute", trigger=inngest.TriggerEvent(event="task/worker-execute"), @@ -102,6 +93,7 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: ) context_event_repo = ContextEventRepository() + dashboard_emitter = get_dashboard_emitter() context_event_repo.add_listener(dashboard_emitter.on_context_event) dashboard_emitter.register_execution( execution_id=payload.execution_id, @@ -166,7 +158,11 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: ) ) - return _worker_execute_result_from_output(output) + return WorkerExecuteResult( + success=output.success, + final_assistant_message=output.output, + error=None if output.success else output.output, + ) async def _persist_context_events( diff --git a/ergon_core/ergon_core/core/runtime/services/communication_service.py b/ergon_core/ergon_core/core/runtime/services/communication_service.py index 04d06778..fc0167f6 100644 --- a/ergon_core/ergon_core/core/runtime/services/communication_service.py +++ b/ergon_core/ergon_core/core/runtime/services/communication_service.py @@ -7,7 +7,7 @@ RunCommunicationMessageDto, RunCommunicationThreadDto, ) -from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import Thread, ThreadMessage from ergon_core.core.runtime.services.communication_schemas import ( @@ -101,7 +101,7 @@ async def save_message(self, request: CreateMessageRequest) -> MessageResponse: created_at=message.created_at, ) try: - await dashboard_emitter.thread_message_created( + await get_dashboard_emitter().thread_message_created( run_id=request.run_id, thread=thread_dto, message=message_dto, diff --git a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py b/ergon_core/ergon_core/core/runtime/services/task_execution_service.py index 28625276..f6c8d653 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_execution_service.py @@ -3,7 +3,7 @@ import logging from uuid import UUID -from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.definitions.models import ( ExperimentDefinition, ExperimentDefinitionTask, @@ -50,7 +50,7 @@ async def _emit_task_status( if node_id is None: return try: - await dashboard_emitter.task_status_changed( + await get_dashboard_emitter().task_status_changed( run_id=run_id, task_id=node_id, task_name=task_slug, diff --git a/ergon_core/ergon_core/core/runtime/services/task_management_service.py b/ergon_core/ergon_core/core/runtime/services/task_management_service.py index 2375564b..b98a2c35 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_management_service.py +++ b/ergon_core/ergon_core/core/runtime/services/task_management_service.py @@ -10,7 +10,8 @@ from uuid import UUID import inngest -from ergon_core.core.dashboard.emitter import dashboard_emitter +from ergon_core.core.dashboard.emitter import DashboardEmitter +from ergon_core.core.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.graph.status_conventions import ( CANCELLED, @@ -105,9 +106,14 @@ class TaskManagementService: the manager's ReAct loop. """ - def __init__(self, graph_repo: WorkflowGraphRepository | None = None) -> None: + def __init__( + self, + graph_repo: WorkflowGraphRepository | None = None, + dashboard_emitter: DashboardEmitter | None = None, + ) -> None: self._graph_repo = graph_repo or WorkflowGraphRepository() - self._graph_repo.add_mutation_listener(dashboard_emitter.graph_mutation) + self._dashboard_emitter = dashboard_emitter or get_dashboard_emitter() + self._graph_repo.add_mutation_listener(self._dashboard_emitter.graph_mutation) # ── add_subtask ────────────────────────────────────────── diff --git a/ergon_core/ergon_core/core/sandbox/instrumentation.py b/ergon_core/ergon_core/core/sandbox/instrumentation.py index e4c0e307..55824fdb 100644 --- a/ergon_core/ergon_core/core/sandbox/instrumentation.py +++ b/ergon_core/ergon_core/core/sandbox/instrumentation.py @@ -6,13 +6,6 @@ from typing import TYPE_CHECKING, Protocol from uuid import UUID -try: - from e2b.sandbox.commands.command_handle import ( - CommandExitException, # type: ignore[import-untyped] - ) -except ImportError: - CommandExitException = Exception # type: ignore[assignment,misc] - from ergon_core.core.sandbox.event_sink import SandboxEventSink from ergon_core.core.sandbox.utils import ( _truncate, @@ -25,7 +18,7 @@ from e2b.sandbox_async.commands.command import Commands # type: ignore[import-untyped] from e2b.sandbox_async.filesystem.filesystem import Filesystem # type: ignore[import-untyped] from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] - + from e2b.sandbox.commands.command_handle import CommandExitException # type: ignore[import-untyped] class SandboxCallResult(Protocol): """Opaque SDK return value forwarded by sandbox proxy methods.""" diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py index 4cb0aea5..c871e533 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py @@ -38,7 +38,7 @@ def __init__( ) -> None: super().__init__( name=name, - criteria=[ResearchRubricsSmokeCriterion(name="researchrubrics-smoke")], + criteria=[ResearchRubricsSmokeCriterion(slug="researchrubrics-smoke")], metadata=metadata, ) @@ -56,7 +56,7 @@ def __init__( ) -> None: super().__init__( name=name, - criteria=[MiniF2FSmokeCriterion(name="minif2f-smoke")], + criteria=[MiniF2FSmokeCriterion(slug="minif2f-smoke")], metadata=metadata, ) @@ -74,7 +74,7 @@ def __init__( ) -> None: super().__init__( name=name, - criteria=[SweBenchSmokeCriterion(name="swebench-smoke")], + criteria=[SweBenchSmokeCriterion(slug="swebench-smoke")], metadata=metadata, ) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py index af63e32e..afa30ab5 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py @@ -17,7 +17,7 @@ class SmokePostRootTimingCriterion(Criterion): async def evaluate(self, context: EvaluationContext) -> CriterionResult: return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=1.0, passed=True, weight=self.weight, @@ -38,7 +38,7 @@ def __init__( ) -> None: super().__init__( name=name, - criteria=[SmokePostRootTimingCriterion(name="smoke-post-root-timing")], + criteria=[SmokePostRootTimingCriterion(slug="smoke-post-root-timing")], metadata=metadata, ) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py index f08ae3c5..e0078af1 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py +++ b/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py @@ -94,7 +94,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: except CriteriaCheckError as e: return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=0.0, passed=False, weight=self.weight, @@ -102,7 +102,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: ) return CriterionResult( slug=self.slug, - name=self.name, + name=self.slug, score=1.0, passed=True, weight=self.weight, diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..7c035d5c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +from collections.abc import Iterator + +import pytest +from ergon_core.core.dashboard.provider import init_dashboard_emitter, reset_dashboard_emitter + + +@pytest.fixture(autouse=True) +def dashboard_emitter_provider() -> Iterator[None]: + reset_dashboard_emitter() + init_dashboard_emitter(enabled=True) + yield + reset_dashboard_emitter() diff --git a/tests/integration/minif2f/test_verification_integration.py b/tests/integration/minif2f/test_verification_integration.py index c5967af2..0f331741 100644 --- a/tests/integration/minif2f/test_verification_integration.py +++ b/tests/integration/minif2f/test_verification_integration.py @@ -113,7 +113,7 @@ async def test_fixture_proof_verifies_to_score_1() -> None: ) criterion = ProofVerificationCriterion( - name="proof_verification", + slug="proof_verification", weight=1.0, max_score=1.0, ) diff --git a/tests/integration/swebench_verified/test_criterion.py b/tests/integration/swebench_verified/test_criterion.py index c8ca2ccf..8089113d 100644 --- a/tests/integration/swebench_verified/test_criterion.py +++ b/tests/integration/swebench_verified/test_criterion.py @@ -87,7 +87,7 @@ def _ctx( async def test_criterion_returns_score_0_for_empty_patch() -> None: """When ``git diff HEAD`` returns an empty tree, score is 0.""" runtime = _mock_runtime(patch_text="") - crit = SWEBenchTestCriterion(name="test-resolution", weight=1.0) + crit = SWEBenchTestCriterion(slug="test-resolution", weight=1.0) ctx = _ctx(output="", runtime=runtime) result = await crit.evaluate(ctx) assert result.score == 0.0 @@ -120,7 +120,7 @@ async def test_criterion_scores_1_when_report_resolved() -> None: }, ), ): - crit = SWEBenchTestCriterion(name="test-resolution", weight=1.0) + crit = SWEBenchTestCriterion(slug="test-resolution", weight=1.0) result = await crit.evaluate(_ctx(runtime=runtime)) assert result.score == 1.0 @@ -153,7 +153,7 @@ async def test_criterion_scores_0_when_report_unresolved() -> None: }, ), ): - crit = SWEBenchTestCriterion(name="test-resolution", weight=1.0) + crit = SWEBenchTestCriterion(slug="test-resolution", weight=1.0) result = await crit.evaluate(_ctx(runtime=runtime)) assert result.score == 0.0 @@ -177,7 +177,7 @@ async def test_criterion_applies_test_patch_then_agent_patch() -> None: return_value={"django__django-1": {"resolved": True, "tests_status": {}}}, ), ): - crit = SWEBenchTestCriterion(name="test-resolution", weight=1.0) + crit = SWEBenchTestCriterion(slug="test-resolution", weight=1.0) await crit.evaluate(_ctx(runtime=runtime)) # reason: RFC 2026-04-22 §3 — post-refactor the criterion writes files via @@ -197,7 +197,7 @@ async def test_criterion_applies_test_patch_then_agent_patch() -> None: @pytest.mark.asyncio async def test_criterion_raises_when_no_runtime_injected() -> None: """Without a runtime, evaluate raises RuntimeError (not AttributeError).""" - crit = SWEBenchTestCriterion(name="test-resolution", weight=1.0) + crit = SWEBenchTestCriterion(slug="test-resolution", weight=1.0) ctx = _ctx(output="some patch text", runtime=None) with ( patch( @@ -224,7 +224,7 @@ async def test_criterion_returns_error_when_install_repo_fails() -> None: "ergon_builtins.benchmarks.swebench_verified.criterion.make_test_spec", return_value=MagicMock(install_repo_script="echo INSTALL", eval_script="echo EVAL"), ): - crit = SWEBenchTestCriterion(name="test-resolution", weight=1.0) + crit = SWEBenchTestCriterion(slug="test-resolution", weight=1.0) result = await crit.evaluate(_ctx(runtime=runtime)) assert result.score == 0.0 diff --git a/tests/integration/swebench_verified/test_rubric.py b/tests/integration/swebench_verified/test_rubric.py index d00645a3..50fba622 100644 --- a/tests/integration/swebench_verified/test_rubric.py +++ b/tests/integration/swebench_verified/test_rubric.py @@ -5,6 +5,6 @@ def test_rubric_contains_single_test_resolution_criterion() -> None: rubric = SWEBenchRubric(name="swebench-rubric") - names = [c.name for c in rubric.criteria] + names = [c.slug for c in rubric.criteria] assert names == ["test-resolution"] assert rubric.criteria[0].weight == 1.0 diff --git a/tests/integration/swebench_verified/test_smoke_e2e.py b/tests/integration/swebench_verified/test_smoke_e2e.py index 5f9417f7..e89a92d2 100644 --- a/tests/integration/swebench_verified/test_smoke_e2e.py +++ b/tests/integration/swebench_verified/test_smoke_e2e.py @@ -53,5 +53,5 @@ def test_build_instances_strips_gold_patch_and_honors_limit() -> None: def test_rubric_instantiates_with_one_criterion() -> None: rubric = SWEBenchRubric(name="swebench-rubric") assert len(rubric.criteria) == 1 - assert rubric.criteria[0].name == "test-resolution" + assert rubric.criteria[0].slug == "test-resolution" assert rubric.criteria[0].weight == 1.0 diff --git a/tests/unit/api/test_criterion_contract.py b/tests/unit/api/test_criterion_contract.py new file mode 100644 index 00000000..ef53e610 --- /dev/null +++ b/tests/unit/api/test_criterion_contract.py @@ -0,0 +1,35 @@ +"""Contracts for the public Criterion base class.""" + +import pytest + +from ergon_core.api.criterion import Criterion +from ergon_core.api.evaluation_context import EvaluationContext +from ergon_core.api.results import CriterionResult, CriterionScoreSpec + + +class _Criterion(Criterion): + type_slug = "test-criterion" + + async def evaluate(self, context: EvaluationContext) -> CriterionResult: + return CriterionResult( + name=self.slug, + score=self.score_spec.max_score, + passed=True, + ) + + +def test_criterion_requires_slug_keyword() -> None: + with pytest.raises(TypeError): + _Criterion(name="legacy-name") # type: ignore[call-arg] + + +def test_criterion_exposes_slug_and_score_spec_without_compatibility_aliases() -> None: + criterion = _Criterion( + slug="canonical-slug", + score_spec=CriterionScoreSpec(max_score=2.5), + ) + + assert criterion.slug == "canonical-slug" + assert criterion.score_spec.max_score == 2.5 + assert not hasattr(criterion, "name") + assert not hasattr(criterion, "max_score") diff --git a/tests/unit/api/test_public_api_imports.py b/tests/unit/api/test_public_api_imports.py index 74970316..d4bca237 100644 --- a/tests/unit/api/test_public_api_imports.py +++ b/tests/unit/api/test_public_api_imports.py @@ -24,6 +24,13 @@ def test_public_api_root_stays_authoring_scoped() -> None: assert not hasattr(public_api, "Tool") +def test_object_first_experiment_run_api_is_retired() -> None: + public_api = importlib.import_module("ergon_core.api") + + assert not hasattr(public_api, "ExperimentRunHandle") + assert not hasattr(public_api.Experiment, "run") + + def test_core_api_app_imports_without_context_payload_cycle() -> None: proc = subprocess.run( [sys.executable, "-c", "import ergon_core.core.api.app; print('ok')"], diff --git a/tests/unit/architecture/test_api_runs_boundary.py b/tests/unit/architecture/test_api_runs_boundary.py new file mode 100644 index 00000000..5c385140 --- /dev/null +++ b/tests/unit/architecture/test_api_runs_boundary.py @@ -0,0 +1,54 @@ +import ast +from pathlib import Path + + +RUNS_API_PATH = ( + Path(__file__).resolve().parents[3] + / "ergon_core" + / "ergon_core" + / "core" + / "api" + / "runs.py" +) + +DOMAIN_HELPERS = { + "_build_communication_threads", + "_build_task_map", + "_context_events_by_task", + "_task_keyed_evaluations", + "_task_keyed_executions", + "_task_keyed_resources", + "_task_keyed_sandboxes", + "_task_timestamps", +} + + +def test_runs_api_does_not_own_run_snapshot_read_model_helpers() -> None: + tree = ast.parse(RUNS_API_PATH.read_text()) + + helper_defs = { + node.name + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name in DOMAIN_HELPERS + } + + assert helper_defs == set() + + +def test_runs_api_does_not_import_persistence_or_sqlmodel() -> None: + tree = ast.parse(RUNS_API_PATH.read_text()) + + imported_modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported_modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module is not None: + imported_modules.add(node.module) + + forbidden = { + module + for module in imported_modules + if module == "sqlmodel" or module.startswith("ergon_core.core.persistence") + } + + assert forbidden == set() diff --git a/tests/unit/architecture/test_public_api_boundaries.py b/tests/unit/architecture/test_public_api_boundaries.py index 610cd348..3b3f2f0a 100644 --- a/tests/unit/architecture/test_public_api_boundaries.py +++ b/tests/unit/architecture/test_public_api_boundaries.py @@ -1,9 +1,19 @@ """Architecture guards for the student-facing public API boundary.""" +import importlib.util from pathlib import Path ROOT = Path(__file__).resolve().parents[3] +REMOVED_PUBLIC_API_MODULES = ( + "ergon_core.api.generation", + "ergon_core.api.json_types", + "ergon_core.api.run_resource", + "ergon_core.api.criterion_runtime", + "ergon_core.api.dependencies", + "ergon_core.api.types", +) + FORBIDDEN_IMPORT_SNIPPETS = ( "from ergon_core.api.generation import", "from ergon_core.api.json_types import", @@ -30,3 +40,13 @@ def test_runtime_and_builtin_code_do_not_import_core_types_through_public_api() offenders.append(f"{path.relative_to(ROOT)} imports via {snippet!r}") assert offenders == [] + + +def test_deleted_public_api_facade_modules_stay_deleted() -> None: + restored = [ + module_name + for module_name in REMOVED_PUBLIC_API_MODULES + if importlib.util.find_spec(module_name) is not None + ] + + assert restored == [] diff --git a/tests/unit/dashboard/test_emitter_provider.py b/tests/unit/dashboard/test_emitter_provider.py new file mode 100644 index 00000000..e000b485 --- /dev/null +++ b/tests/unit/dashboard/test_emitter_provider.py @@ -0,0 +1,44 @@ +import pytest + +from ergon_core.core.dashboard.emitter import DashboardEmitter +from ergon_core.core.dashboard.provider import ( + get_dashboard_emitter, + init_dashboard_emitter, + reset_dashboard_emitter, + set_dashboard_emitter, +) + + +def test_dashboard_emitter_provider_requires_startup_initialization() -> None: + reset_dashboard_emitter() + + with pytest.raises(RuntimeError, match="DashboardEmitter has not been initialized"): + get_dashboard_emitter() + + +def test_init_dashboard_emitter_installs_process_instance() -> None: + reset_dashboard_emitter() + + emitter = init_dashboard_emitter(enabled=True) + + assert isinstance(emitter, DashboardEmitter) + assert get_dashboard_emitter() is emitter + + +def test_set_dashboard_emitter_installs_injected_instance() -> None: + reset_dashboard_emitter() + emitter = DashboardEmitter(enabled=False) + + set_dashboard_emitter(emitter) + + assert get_dashboard_emitter() is emitter + + +def test_reset_dashboard_emitter_clears_process_instance() -> None: + reset_dashboard_emitter() + init_dashboard_emitter(enabled=True) + + reset_dashboard_emitter() + + with pytest.raises(RuntimeError, match="DashboardEmitter has not been initialized"): + get_dashboard_emitter() diff --git a/tests/unit/runtime/test_communication_service.py b/tests/unit/runtime/test_communication_service.py index 79bb43b3..0dc1ac2f 100644 --- a/tests/unit/runtime/test_communication_service.py +++ b/tests/unit/runtime/test_communication_service.py @@ -2,6 +2,8 @@ from uuid import uuid4 import pytest +from ergon_core.core.dashboard.emitter import DashboardEmitter +from ergon_core.core.dashboard.provider import reset_dashboard_emitter, set_dashboard_emitter from ergon_core.core.runtime.services import communication_service as module from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest from sqlalchemy.pool import StaticPool @@ -10,6 +12,13 @@ Thread = module.Thread +@pytest.fixture(autouse=True) +def dashboard_emitter_provider() -> Iterator[None]: + reset_dashboard_emitter() + yield + reset_dashboard_emitter() + + @pytest.fixture() def session_factory() -> Iterator[tuple[Session, object]]: engine = create_engine( @@ -36,7 +45,9 @@ async def _record_thread_event(*, run_id: object, thread: object, message: objec emitted.append((thread, message)) monkeypatch.setattr(module, "get_session", session_factory) - monkeypatch.setattr(module.dashboard_emitter, "thread_message_created", _record_thread_event) + emitter = DashboardEmitter(enabled=True) + monkeypatch.setattr(emitter, "thread_message_created", _record_thread_event) + set_dashboard_emitter(emitter) run_id = uuid4() execution_id = uuid4() @@ -73,7 +84,9 @@ async def _ignore_thread_event(*, run_id: object, thread: object, message: objec return None monkeypatch.setattr(module, "get_session", session_factory) - monkeypatch.setattr(module.dashboard_emitter, "thread_message_created", _ignore_thread_event) + emitter = DashboardEmitter(enabled=True) + monkeypatch.setattr(emitter, "thread_message_created", _ignore_thread_event) + set_dashboard_emitter(emitter) service = module.CommunicationService() run_id = uuid4() diff --git a/tests/unit/runtime/test_evaluation_summary_contracts.py b/tests/unit/runtime/test_evaluation_summary_contracts.py index a33bbfbf..87d6d92c 100644 --- a/tests/unit/runtime/test_evaluation_summary_contracts.py +++ b/tests/unit/runtime/test_evaluation_summary_contracts.py @@ -27,7 +27,7 @@ class _Criterion(Criterion): type_slug = "test-criterion" async def evaluate(self, context: EvaluationContext) -> CriterionResult: - return CriterionResult(name=self.name, score=1.0, passed=True) + return CriterionResult(name=self.slug, score=1.0, passed=True) def _service_result( diff --git a/tests/unit/runtime/test_import_boundaries.py b/tests/unit/runtime/test_import_boundaries.py index fe31fe67..c3ad1719 100644 --- a/tests/unit/runtime/test_import_boundaries.py +++ b/tests/unit/runtime/test_import_boundaries.py @@ -19,3 +19,10 @@ def test_context_event_payloads_use_shared_logprob_type_without_api_cycle() -> N assert ContextEventPayload is ContextPartChunkLog assert ContextPartChunkLog.model_fields["logprobs"].annotation == list[TokenLogprob] | None + + +def test_worker_execute_does_not_expose_result_adapter_helpers() -> None: + import ergon_core.core.runtime.inngest.worker_execute as worker_execute + + assert not hasattr(worker_execute, "_worker_execute_result_from_output") + assert not hasattr(worker_execute, "_worker_execute_result_from_exception") diff --git a/tests/unit/runtime/test_inngest_criterion_executor.py b/tests/unit/runtime/test_inngest_criterion_executor.py index 070582c7..9a9e257d 100644 --- a/tests/unit/runtime/test_inngest_criterion_executor.py +++ b/tests/unit/runtime/test_inngest_criterion_executor.py @@ -33,12 +33,12 @@ class _Criterion(Criterion): type_slug = "test-criterion" def __init__(self) -> None: - super().__init__(name="criterion") + super().__init__(slug="criterion") self.runtime_task_scope = None async def evaluate(self, context: EvaluationContext) -> CriterionResult: self.runtime_task_scope = context.runtime.task_scope - return CriterionResult(name=self.name, score=1.0, passed=True) + return CriterionResult(name=self.slug, score=1.0, passed=True) @pytest.mark.asyncio diff --git a/tests/unit/runtime/test_rubric_evaluation_service.py b/tests/unit/runtime/test_rubric_evaluation_service.py index 96d7a6cc..0d499912 100644 --- a/tests/unit/runtime/test_rubric_evaluation_service.py +++ b/tests/unit/runtime/test_rubric_evaluation_service.py @@ -20,15 +20,15 @@ class _Criterion(Criterion): type_slug = "test-criterion" - def __init__(self, *, name: str, weight: float, max_score: float) -> None: + def __init__(self, *, slug: str, weight: float, max_score: float) -> None: super().__init__( - name=name, + slug=slug, weight=weight, score_spec=CriterionScoreSpec(max_score=max_score), ) async def evaluate(self, context: EvaluationContext) -> CriterionResult: - return CriterionResult(name=self.name, score=self.max_score, passed=True) + return CriterionResult(name=self.slug, score=self.score_spec.max_score, passed=True) class _Executor: @@ -45,7 +45,7 @@ async def execute_all( self.seen_specs = criteria return [ CriterionResult( - name=spec.criterion.name, + name=spec.criterion.slug, score=spec.max_score, passed=True, weight=spec.criterion.weight, @@ -61,8 +61,8 @@ async def test_rubric_service_uses_criterion_max_score_not_signed_weight() -> No evaluator = Rubric( name="rubric", criteria=[ - _Criterion(name="positive", weight=2.0, max_score=2.0), - _Criterion(name="negative", weight=-5.0, max_score=5.0), + _Criterion(slug="positive", weight=2.0, max_score=2.0), + _Criterion(slug="negative", weight=-5.0, max_score=5.0), ], ) diff --git a/tests/unit/runtime/test_worker_execute_output_failure.py b/tests/unit/runtime/test_worker_execute_output_failure.py deleted file mode 100644 index f421a542..00000000 --- a/tests/unit/runtime/test_worker_execute_output_failure.py +++ /dev/null @@ -1,12 +0,0 @@ -from ergon_core.api.results import WorkerOutput -from ergon_core.core.runtime.inngest.worker_execute import _worker_execute_result_from_output - - -def test_worker_execute_result_preserves_worker_output_failure() -> None: - result = _worker_execute_result_from_output( - WorkerOutput(output="probe failed", success=False), - ) - - assert result.success is False - assert result.final_assistant_message == "probe failed" - assert result.error == "probe failed" diff --git a/tests/unit/smoke_base/test_minif2f_criterion.py b/tests/unit/smoke_base/test_minif2f_criterion.py index 6ae2e2d9..87e38a6a 100644 --- a/tests/unit/smoke_base/test_minif2f_criterion.py +++ b/tests/unit/smoke_base/test_minif2f_criterion.py @@ -8,7 +8,7 @@ def _crit() -> MiniF2FSmokeCriterion: - return MiniF2FSmokeCriterion(name="unit-test") + return MiniF2FSmokeCriterion(slug="unit-test") @pytest.mark.asyncio diff --git a/tests/unit/smoke_base/test_researchrubrics_criterion.py b/tests/unit/smoke_base/test_researchrubrics_criterion.py index 9023d2de..ffc8ae7d 100644 --- a/tests/unit/smoke_base/test_researchrubrics_criterion.py +++ b/tests/unit/smoke_base/test_researchrubrics_criterion.py @@ -14,7 +14,7 @@ def _crit() -> ResearchRubricsSmokeCriterion: - return ResearchRubricsSmokeCriterion(name="unit-test") + return ResearchRubricsSmokeCriterion(slug="unit-test") # ============================================================================= diff --git a/tests/unit/smoke_base/test_swebench_criterion.py b/tests/unit/smoke_base/test_swebench_criterion.py index c0079264..e1bde822 100644 --- a/tests/unit/smoke_base/test_swebench_criterion.py +++ b/tests/unit/smoke_base/test_swebench_criterion.py @@ -8,7 +8,7 @@ def _crit() -> SweBenchSmokeCriterion: - return SweBenchSmokeCriterion(name="unit-test") + return SweBenchSmokeCriterion(slug="unit-test") @pytest.mark.asyncio diff --git a/tests/unit/state/test_llm_judge_runtime_injection.py b/tests/unit/state/test_llm_judge_runtime_injection.py index d47df210..9e963b83 100644 --- a/tests/unit/state/test_llm_judge_runtime_injection.py +++ b/tests/unit/state/test_llm_judge_runtime_injection.py @@ -73,7 +73,7 @@ async def test_evaluate_verdict(self, monkeypatch, passed, expected_score, reaso ) criterion = LLMJudgeCriterion( - name="test-criterion", + slug="test-criterion", prompt_template="Evaluate whether the report covers the topic.", weight=1.0, max_score=1.0, @@ -101,14 +101,14 @@ class _SimpleCriterion(Criterion): async def evaluate(self, context: EvaluationContext) -> CriterionResult: return CriterionResult( - name=self.name, + name=self.slug, score=1.0, passed=True, weight=self.weight, feedback="Always passes", ) - criterion = _SimpleCriterion(name="simple") + criterion = _SimpleCriterion(slug="simple") fake_runtime = AsyncMock() ctx = _make_eval_context(runtime=fake_runtime) result = await criterion.evaluate(ctx) diff --git a/tests/unit/test_rollouts_di.py b/tests/unit/test_rollouts_di.py new file mode 100644 index 00000000..e542ae1b --- /dev/null +++ b/tests/unit/test_rollouts_di.py @@ -0,0 +1,62 @@ +from uuid import uuid4 + +from ergon_core.core.api.rollouts import router +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +class _FakeRolloutService: + def __init__(self) -> None: + self.batch_id = uuid4() + self.run_id = uuid4() + + def submit(self, _request: object) -> dict[str, object]: + return { + "batch_id": self.batch_id, + "run_ids": [self.run_id], + "status": "pending", + } + + +class _FakeVLLMManager: + def __init__(self) -> None: + self.restarted_with: str | None = None + + def restart(self, checkpoint_path: str) -> None: + self.restarted_with = checkpoint_path + + +def test_rollout_router_gets_service_from_app_state() -> None: + app = FastAPI() + app.state.rollout_service = _FakeRolloutService() + app.include_router(router) + client = TestClient(app) + + resp = client.post( + "/rollouts/submit", + json={ + "definition_id": str(uuid4()), + "num_episodes": 1, + }, + ) + + assert resp.status_code == 202 + + +def test_sync_weights_gets_vllm_manager_from_app_state() -> None: + manager = _FakeVLLMManager() + app = FastAPI() + app.state.vllm_manager = manager + app.include_router(router) + client = TestClient(app) + + resp = client.post( + "/rollouts/sync-weights", + json={ + "checkpoint_path": "/tmp/checkpoint", + "model_name": "ignored-by-manager", + }, + ) + + assert resp.status_code == 200 + assert manager.restarted_with == "/tmp/checkpoint" diff --git a/tests/unit/test_test_harness.py b/tests/unit/test_test_harness.py index 6b041e5e..027463de 100644 --- a/tests/unit/test_test_harness.py +++ b/tests/unit/test_test_harness.py @@ -5,7 +5,7 @@ import pytest from ergon_core.core.api import test_harness -from ergon_core.core.api.startup_plugins import run_startup_plugins +from ergon_core.core.api.app import _run_startup_plugins from ergon_core.core.api.test_harness import get_session_dep, router from fastapi import FastAPI from fastapi.testclient import TestClient @@ -112,4 +112,4 @@ def test_reset_requires_secret_header(monkeypatch: pytest.MonkeyPatch) -> None: def test_startup_plugin_loader_rejects_invalid_specs() -> None: with pytest.raises(RuntimeError, match="expected 'module:function'"): - run_startup_plugins(("ergon_core.test_support.smoke_fixtures",)) + _run_startup_plugins(("ergon_core.test_support.smoke_fixtures",)) From 0da06aa505c401eaf6312f59a1ffc00456a254d9 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:44 +0100 Subject: [PATCH 42/66] docs: capture cleanup and layout plans Made-with: Cursor --- ...ore-hybrid-domain-layout-implementation.md | 1259 ++++++++++++ .../2026-04-28-core-hybrid-domain-layout.md | 584 ++++++ ...-04-28-ergon-builtins-rebuild-structure.md | 709 +++++++ ...2026-04-28-ergon-cli-refactor-structure.md | 772 +++++++ ...2026-04-28-ergon-e2e-refactor-test-plan.md | 831 ++++++++ ...026-04-28-runtime-services-layout-audit.md | 200 +- ...-04-29-core-component-registry-refactor.md | 1229 ++++++++++++ ...-04-29-finish-builtins-cli-e2e-refactor.md | 841 ++++++++ ...stent-component-catalog-and-test-layout.md | 1784 +++++++++++++++++ 9 files changed, 8178 insertions(+), 31 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout-implementation.md create mode 100644 docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout.md create mode 100644 docs/superpowers/plans/2026-04-28-ergon-builtins-rebuild-structure.md create mode 100644 docs/superpowers/plans/2026-04-28-ergon-cli-refactor-structure.md create mode 100644 docs/superpowers/plans/2026-04-28-ergon-e2e-refactor-test-plan.md create mode 100644 docs/superpowers/plans/2026-04-29-core-component-registry-refactor.md create mode 100644 docs/superpowers/plans/2026-04-29-finish-builtins-cli-e2e-refactor.md create mode 100644 docs/superpowers/plans/2026-04-29-persistent-component-catalog-and-test-layout.md diff --git a/docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout-implementation.md b/docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout-implementation.md new file mode 100644 index 00000000..a26fa51b --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout-implementation.md @@ -0,0 +1,1259 @@ +# Core Hybrid Domain Layout Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move `ergon_core.core` to the approved hybrid layout: thin `rest_api`, product use cases under `application`, pure objects under `domain`, adapters under `infrastructure`, SQL rows under `persistence`, and `rl` kept as a separate bounded context. + +**Architecture:** This is a mechanical package migration with architecture guards. Each slice moves one cluster, bulk-renames imports, runs focused tests, and preserves behavior. A temporary exact-folder-structure test is added first and deleted at the end after durable architecture tests cover the important constraints. + +**Tech Stack:** Python, pytest, ruff, SQLModel, FastAPI, Inngest, Pydantic. + +**Commit Policy:** Do not create git commits unless the user explicitly asks. Treat each task's test run as the checkpoint. + +--- + +## Target Clusters + +The implementation follows `docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout.md`. + +```text +core/ + rest_api/ + application/ + experiments/ + workflows/ + graph/ + tasks/ + evaluation/ + read_models/ + communication/ + context/ + jobs/ + resources/ + events/ + domain/ + experiments/ + generation/ + persistence/ + infrastructure/ + inngest/ + handlers/ + sandbox/ + dashboard/ + tracing/ + dependencies.py + rl/ + shared/ +``` + +## Task 1: Add Temporary Exact Layout Guard + +**Files:** +- Create: `tests/unit/architecture/test_core_hybrid_layout_temporary.py` +- Modify: none +- Test: `tests/unit/architecture/test_core_hybrid_layout_temporary.py` + +- [ ] **Step 1: Add the temporary failing test** + +Create `tests/unit/architecture/test_core_hybrid_layout_temporary.py`: + +```python +"""Temporary guard for the core hybrid layout migration. + +Delete this file in the final migration task. It intentionally asserts the +exact file tree so each migration slice has a visible end state. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +CORE = ROOT / "ergon_core/ergon_core/core" + +EXPECTED_FILES = { + "__init__.py", + "rest_api/__init__.py", + "rest_api/app.py", + "rest_api/cohorts.py", + "rest_api/experiments.py", + "rest_api/rollouts.py", + "rest_api/runs.py", + "rest_api/test_harness.py", + "application/__init__.py", + "application/experiments/__init__.py", + "application/experiments/service.py", + "application/experiments/models.py", + "application/experiments/repository.py", + "application/experiments/definition_writer.py", + "application/experiments/launch.py", + "application/workflows/__init__.py", + "application/workflows/service.py", + "application/workflows/orchestration.py", + "application/workflows/runs.py", + "application/workflows/models.py", + "application/workflows/errors.py", + "application/graph/__init__.py", + "application/graph/repository.py", + "application/graph/propagation.py", + "application/graph/traversal.py", + "application/graph/lookup.py", + "application/graph/models.py", + "application/graph/errors.py", + "application/tasks/__init__.py", + "application/tasks/service.py", + "application/tasks/execution.py", + "application/tasks/management.py", + "application/tasks/inspection.py", + "application/tasks/cleanup.py", + "application/tasks/repository.py", + "application/tasks/models.py", + "application/tasks/errors.py", + "application/evaluation/__init__.py", + "application/evaluation/service.py", + "application/evaluation/executors.py", + "application/evaluation/inngest_executor.py", + "application/evaluation/criterion_runtime.py", + "application/evaluation/scoring.py", + "application/evaluation/protocols.py", + "application/evaluation/models.py", + "application/evaluation/errors.py", + "application/read_models/__init__.py", + "application/read_models/runs.py", + "application/read_models/run_snapshot.py", + "application/read_models/experiments.py", + "application/read_models/cohorts.py", + "application/read_models/resources.py", + "application/read_models/models.py", + "application/read_models/errors.py", + "application/communication/__init__.py", + "application/communication/service.py", + "application/communication/models.py", + "application/communication/errors.py", + "application/context/__init__.py", + "application/context/events.py", + "application/context/output_extraction.py", + "application/jobs/__init__.py", + "application/jobs/cancel_orphan_subtasks.py", + "application/jobs/check_evaluators.py", + "application/jobs/cleanup_cancelled_task.py", + "application/jobs/complete_workflow.py", + "application/jobs/evaluate_task_run.py", + "application/jobs/execute_task.py", + "application/jobs/fail_workflow.py", + "application/jobs/persist_outputs.py", + "application/jobs/propagate_execution.py", + "application/jobs/run_cleanup.py", + "application/jobs/sandbox_setup.py", + "application/jobs/start_workflow.py", + "application/jobs/worker_execute.py", + "application/jobs/models.py", + "application/resources/__init__.py", + "application/resources/repository.py", + "application/resources/models.py", + "application/events/__init__.py", + "application/events/base.py", + "application/events/task_events.py", + "application/events/infrastructure_events.py", + "domain/__init__.py", + "domain/experiments/__init__.py", + "domain/experiments/experiment.py", + "domain/experiments/handles.py", + "domain/experiments/worker_spec.py", + "domain/experiments/validation.py", + "domain/generation/__init__.py", + "domain/generation/context_parts.py", + "persistence/shared/__init__.py", + "persistence/shared/db.py", + "persistence/shared/enums.py", + "persistence/shared/ids.py", + "persistence/shared/types.py", + "persistence/definitions/__init__.py", + "persistence/definitions/models.py", + "persistence/telemetry/__init__.py", + "persistence/telemetry/models.py", + "persistence/telemetry/repositories.py", + "persistence/telemetry/evaluation_summary.py", + "persistence/graph/__init__.py", + "persistence/graph/models.py", + "persistence/graph/status_conventions.py", + "persistence/context/__init__.py", + "persistence/context/models.py", + "persistence/context/event_payloads.py", + "persistence/saved_specs/__init__.py", + "persistence/saved_specs/models.py", + "infrastructure/__init__.py", + "infrastructure/inngest/__init__.py", + "infrastructure/inngest/client.py", + "infrastructure/inngest/registry.py", + "infrastructure/inngest/contracts.py", + "infrastructure/inngest/errors.py", + "infrastructure/inngest/handlers/__init__.py", + "infrastructure/inngest/handlers/cancel_orphan_subtasks.py", + "infrastructure/inngest/handlers/check_evaluators.py", + "infrastructure/inngest/handlers/cleanup_cancelled_task.py", + "infrastructure/inngest/handlers/complete_workflow.py", + "infrastructure/inngest/handlers/evaluate_task_run.py", + "infrastructure/inngest/handlers/execute_task.py", + "infrastructure/inngest/handlers/fail_workflow.py", + "infrastructure/inngest/handlers/persist_outputs.py", + "infrastructure/inngest/handlers/propagate_execution.py", + "infrastructure/inngest/handlers/run_cleanup.py", + "infrastructure/inngest/handlers/sandbox_setup.py", + "infrastructure/inngest/handlers/start_workflow.py", + "infrastructure/inngest/handlers/worker_execute.py", + "infrastructure/sandbox/__init__.py", + "infrastructure/sandbox/manager.py", + "infrastructure/sandbox/lifecycle.py", + "infrastructure/sandbox/resource_publisher.py", + "infrastructure/sandbox/instrumentation.py", + "infrastructure/sandbox/event_sink.py", + "infrastructure/sandbox/errors.py", + "infrastructure/sandbox/utils.py", + "infrastructure/dashboard/__init__.py", + "infrastructure/dashboard/emitter.py", + "infrastructure/dashboard/provider.py", + "infrastructure/dashboard/event_contracts.py", + "infrastructure/tracing/__init__.py", + "infrastructure/tracing/attributes.py", + "infrastructure/tracing/contexts.py", + "infrastructure/tracing/ids.py", + "infrastructure/tracing/noop.py", + "infrastructure/tracing/otel.py", + "infrastructure/tracing/sinks.py", + "infrastructure/tracing/types.py", + "infrastructure/dependencies.py", + "rl/__init__.py", + "rl/rollout_service.py", + "rl/eval_runner.py", + "rl/extraction.py", + "rl/rewards.py", + "rl/checkpoint.py", + "rl/rollout_types.py", + "rl/vllm_manager.py", + "shared/__init__.py", + "shared/json_types.py", + "shared/settings.py", + "shared/utils.py", +} + +REMOVED_DIRS = { + "api", + "definitions", + "composition", + "runtime", + "sandbox", + "dashboard", +} + +REMOVED_ROOT_FILES = { + "generation.py", + "json_types.py", + "settings.py", + "utils.py", +} + + +def test_core_has_exact_target_layout_during_migration() -> None: + actual_files = { + str(path.relative_to(CORE)) + for path in CORE.rglob("*.py") + if "__pycache__" not in path.parts + } + missing = sorted(EXPECTED_FILES - actual_files) + unexpected = sorted(actual_files - EXPECTED_FILES) + + assert missing == [] + assert unexpected == [] + + +def test_old_core_roots_are_removed_during_migration() -> None: + restored_dirs = sorted(name for name in REMOVED_DIRS if (CORE / name).exists()) + restored_files = sorted(name for name in REMOVED_ROOT_FILES if (CORE / name).exists()) + + assert restored_dirs == [] + assert restored_files == [] +``` + +- [ ] **Step 2: Run the temporary test and confirm it fails** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_core_hybrid_layout_temporary.py -q +``` + +Expected: FAIL because the target directories do not exist yet and old roots still exist. + +## Task 2: Rename HTTP Layer To `core/rest_api` + +**Files:** +- Move: `ergon_core/ergon_core/core/api/*` -> `ergon_core/ergon_core/core/rest_api/*` +- Modify: imports in `ergon_core/ergon_core/core/rest_api/*.py` +- Modify: imports across `ergon_core`, `ergon_cli`, `ergon_builtins`, and `tests` +- Test: `tests/unit/architecture/test_public_api_boundaries.py` +- Test: `tests/unit/architecture/test_core_schema_sources.py` + +- [ ] **Step 1: Move the package** + +Move files: + +```bash +mkdir -p ergon_core/ergon_core/core/rest_api +mv ergon_core/ergon_core/core/api/*.py ergon_core/ergon_core/core/rest_api/ +rmdir ergon_core/ergon_core/core/api +``` + +- [ ] **Step 2: Bulk update imports** + +Replace every `ergon_core.core.api` import with `ergon_core.core.rest_api`. + +Run: + +```bash +python - <<'PY' +from pathlib import Path + +for root in [Path("ergon_core"), Path("ergon_cli"), Path("ergon_builtins"), Path("tests")]: + for path in root.rglob("*.py"): + text = path.read_text() + new = text.replace("ergon_core.core.api", "ergon_core.core.rest_api") + if new != text: + path.write_text(new) +PY +``` + +- [ ] **Step 3: Add a durable architecture guard** + +In `tests/unit/architecture/test_public_api_boundaries.py`, add: + +```python +def test_internal_http_api_is_named_rest_api_not_core_api() -> None: + core_root = ROOT / "ergon_core" / "ergon_core" / "core" + + assert not (core_root / "api").exists() + assert (core_root / "rest_api").exists() +``` + +- [ ] **Step 4: Run focused tests** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_public_api_boundaries.py tests/unit/architecture/test_core_schema_sources.py -q +``` + +Expected: PASS for durable architecture tests. The temporary exact-layout test still fails until the full migration finishes. + +## Task 3: Move Shared Primitives And Pure Domain Objects + +**Files:** +- Move: `core/json_types.py` -> `core/shared/json_types.py` +- Move: `core/settings.py` -> `core/shared/settings.py` +- Move: `core/utils.py` -> `core/shared/utils.py` +- Move: `core/generation.py` -> `core/domain/generation/context_parts.py` +- Move: `core/composition/*` -> `core/domain/experiments/*` +- Create: `core/shared/__init__.py` +- Create: `core/domain/__init__.py` +- Create: `core/domain/generation/__init__.py` +- Modify: imports across source and tests +- Test: `tests/unit/architecture/test_public_api_boundaries.py` +- Test: `tests/unit/architecture/test_core_schema_sources.py` + +- [ ] **Step 1: Move shared files** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/shared +mv ergon_core/ergon_core/core/json_types.py ergon_core/ergon_core/core/shared/json_types.py +mv ergon_core/ergon_core/core/settings.py ergon_core/ergon_core/core/shared/settings.py +mv ergon_core/ergon_core/core/utils.py ergon_core/ergon_core/core/shared/utils.py +touch ergon_core/ergon_core/core/shared/__init__.py +``` + +- [ ] **Step 2: Move generation primitives** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/domain/generation +mv ergon_core/ergon_core/core/generation.py ergon_core/ergon_core/core/domain/generation/context_parts.py +touch ergon_core/ergon_core/core/domain/__init__.py +touch ergon_core/ergon_core/core/domain/generation/__init__.py +``` + +- [ ] **Step 3: Move experiment composition domain** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/domain/experiments +mv ergon_core/ergon_core/core/composition/*.py ergon_core/ergon_core/core/domain/experiments/ +rmdir ergon_core/ergon_core/core/composition +``` + +- [ ] **Step 4: Bulk update imports** + +Run: + +```bash +python - <<'PY' +from pathlib import Path + +replacements = { + "ergon_core.core.json_types": "ergon_core.core.shared.json_types", + "ergon_core.core.settings": "ergon_core.core.shared.settings", + "ergon_core.core.utils": "ergon_core.core.shared.utils", + "ergon_core.core.generation": "ergon_core.core.domain.generation.context_parts", + "ergon_core.core.composition": "ergon_core.core.domain.experiments", +} + +for root in [Path("ergon_core"), Path("ergon_cli"), Path("ergon_builtins"), Path("tests")]: + for path in root.rglob("*.py"): + text = path.read_text() + new = text + for old, replacement in replacements.items(): + new = new.replace(old, replacement) + if new != text: + path.write_text(new) +PY +``` + +- [ ] **Step 5: Restore domain exports** + +Ensure `ergon_core/ergon_core/core/domain/experiments/__init__.py` exports the same names previously exported by `core/composition/__init__.py`: + +```python +from ergon_core.core.domain.experiments.experiment import Experiment +from ergon_core.core.domain.experiments.handles import DefinitionHandle +from ergon_core.core.domain.experiments.worker_spec import WorkerSpec + +__all__ = ["DefinitionHandle", "Experiment", "WorkerSpec"] +``` + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_public_api_boundaries.py tests/unit/architecture/test_core_schema_sources.py tests/unit/api/test_public_api_imports.py -q +``` + +Expected: PASS. + +## Task 4: Move Experiment Application Cluster + +**Files:** +- Move: `core/definitions/service.py` -> `core/application/experiments/service.py` +- Move: `core/definitions/schemas.py` -> `core/application/experiments/models.py` +- Move: `core/definitions/repository.py` -> `core/application/experiments/repository.py` +- Move: `core/definitions/persistence.py` -> `core/application/experiments/definition_writer.py` +- Move: `core/runtime/workflows/launch.py` -> `core/application/experiments/launch.py` +- Create: `core/application/__init__.py` +- Create: `core/application/experiments/__init__.py` +- Delete: `core/definitions/` +- Test: `tests/unit/runtime/test_experiment_definition_service.py` +- Test: `tests/unit/runtime/test_experiment_launch_service.py` +- Test: `tests/unit/cli/test_experiment_cli.py` + +- [ ] **Step 1: Move files** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/application/experiments +mv ergon_core/ergon_core/core/definitions/service.py ergon_core/ergon_core/core/application/experiments/service.py +mv ergon_core/ergon_core/core/definitions/schemas.py ergon_core/ergon_core/core/application/experiments/models.py +mv ergon_core/ergon_core/core/definitions/repository.py ergon_core/ergon_core/core/application/experiments/repository.py +mv ergon_core/ergon_core/core/definitions/persistence.py ergon_core/ergon_core/core/application/experiments/definition_writer.py +mv ergon_core/ergon_core/core/runtime/workflows/launch.py ergon_core/ergon_core/core/application/experiments/launch.py +touch ergon_core/ergon_core/core/application/__init__.py +touch ergon_core/ergon_core/core/application/experiments/__init__.py +rm ergon_core/ergon_core/core/definitions/__init__.py +rmdir ergon_core/ergon_core/core/definitions +``` + +- [ ] **Step 2: Bulk update imports** + +Run: + +```bash +python - <<'PY' +from pathlib import Path + +replacements = { + "ergon_core.core.definitions.service": "ergon_core.core.application.experiments.service", + "ergon_core.core.definitions.schemas": "ergon_core.core.application.experiments.models", + "ergon_core.core.definitions.repository": "ergon_core.core.application.experiments.repository", + "ergon_core.core.definitions.persistence": "ergon_core.core.application.experiments.definition_writer", + "ergon_core.core.runtime.workflows.launch": "ergon_core.core.application.experiments.launch", +} + +for root in [Path("ergon_core"), Path("ergon_cli"), Path("ergon_builtins"), Path("tests")]: + for path in root.rglob("*.py"): + text = path.read_text() + new = text + for old, replacement in replacements.items(): + new = new.replace(old, replacement) + if new != text: + path.write_text(new) +PY +``` + +- [ ] **Step 3: Ensure experiment package exports the front door** + +Set `ergon_core/ergon_core/core/application/experiments/__init__.py` to: + +```python +from ergon_core.core.application.experiments.service import ExperimentService + +__all__ = ["ExperimentService"] +``` + +- [ ] **Step 4: Run focused tests** + +Run: + +```bash +uv run pytest tests/unit/runtime/test_experiment_definition_service.py tests/unit/runtime/test_experiment_launch_service.py tests/unit/cli/test_experiment_cli.py -q +``` + +Expected: PASS. + +## Task 5: Move Workflow, Graph, Task, And Evaluation Application Clusters + +**Files:** +- Move: `core/runtime/workflows/{service,orchestration,runs,models,errors}.py` -> `core/application/workflows/` +- Move: `core/runtime/graph/{repository,propagation,traversal,lookup,dto,errors}.py` -> `core/application/graph/` +- Rename: `core/application/graph/dto.py` -> `core/application/graph/models.py` +- Move: `core/runtime/tasks/*` -> `core/application/tasks/` +- Rename: `core/application/tasks/management.py` remains `management.py` +- Create: `core/application/tasks/service.py` if needed as a package front door +- Move: `core/runtime/evaluation/*` -> `core/application/evaluation/` +- Modify: imports across source and tests +- Test: runtime workflow/task/evaluation tests + +- [ ] **Step 1: Move workflows** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/application/workflows +mv ergon_core/ergon_core/core/runtime/workflows/service.py ergon_core/ergon_core/core/application/workflows/service.py +mv ergon_core/ergon_core/core/runtime/workflows/orchestration.py ergon_core/ergon_core/core/application/workflows/orchestration.py +mv ergon_core/ergon_core/core/runtime/workflows/runs.py ergon_core/ergon_core/core/application/workflows/runs.py +mv ergon_core/ergon_core/core/runtime/workflows/models.py ergon_core/ergon_core/core/application/workflows/models.py +mv ergon_core/ergon_core/core/runtime/workflows/errors.py ergon_core/ergon_core/core/application/workflows/errors.py +touch ergon_core/ergon_core/core/application/workflows/__init__.py +rm -f ergon_core/ergon_core/core/runtime/workflows/__init__.py +rmdir ergon_core/ergon_core/core/runtime/workflows +``` + +- [ ] **Step 2: Move graph** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/application/graph +mv ergon_core/ergon_core/core/runtime/graph/repository.py ergon_core/ergon_core/core/application/graph/repository.py +mv ergon_core/ergon_core/core/runtime/graph/propagation.py ergon_core/ergon_core/core/application/graph/propagation.py +mv ergon_core/ergon_core/core/runtime/graph/traversal.py ergon_core/ergon_core/core/application/graph/traversal.py +mv ergon_core/ergon_core/core/runtime/graph/lookup.py ergon_core/ergon_core/core/application/graph/lookup.py +mv ergon_core/ergon_core/core/runtime/graph/dto.py ergon_core/ergon_core/core/application/graph/models.py +mv ergon_core/ergon_core/core/runtime/graph/errors.py ergon_core/ergon_core/core/application/graph/errors.py +touch ergon_core/ergon_core/core/application/graph/__init__.py +rm -f ergon_core/ergon_core/core/runtime/graph/__init__.py +rmdir ergon_core/ergon_core/core/runtime/graph +``` + +- [ ] **Step 3: Move tasks** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/application/tasks +mv ergon_core/ergon_core/core/runtime/tasks/*.py ergon_core/ergon_core/core/application/tasks/ +touch ergon_core/ergon_core/core/application/tasks/service.py +rmdir ergon_core/ergon_core/core/runtime/tasks +``` + +Set `ergon_core/ergon_core/core/application/tasks/service.py` to: + +```python +"""Task application package front door. + +Task lifecycle behavior currently lives in focused modules: +`execution`, `management`, `inspection`, and `cleanup`. +""" + +from ergon_core.core.application.tasks.execution import TaskExecutionService +from ergon_core.core.application.tasks.management import TaskManagementService + +__all__ = ["TaskExecutionService", "TaskManagementService"] +``` + +- [ ] **Step 4: Move evaluation** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/application/evaluation +mv ergon_core/ergon_core/core/runtime/evaluation/*.py ergon_core/ergon_core/core/application/evaluation/ +touch ergon_core/ergon_core/core/application/evaluation/__init__.py +rmdir ergon_core/ergon_core/core/runtime/evaluation +``` + +- [ ] **Step 5: Bulk update imports** + +Run: + +```bash +python - <<'PY' +from pathlib import Path + +replacements = { + "ergon_core.core.runtime.workflows": "ergon_core.core.application.workflows", + "ergon_core.core.runtime.graph.dto": "ergon_core.core.application.graph.models", + "ergon_core.core.runtime.graph": "ergon_core.core.application.graph", + "ergon_core.core.runtime.tasks": "ergon_core.core.application.tasks", + "ergon_core.core.runtime.evaluation": "ergon_core.core.application.evaluation", +} + +for root in [Path("ergon_core"), Path("ergon_cli"), Path("ergon_builtins"), Path("tests")]: + for path in root.rglob("*.py"): + text = path.read_text() + new = text + for old, replacement in replacements.items(): + new = new.replace(old, replacement) + if new != text: + path.write_text(new) +PY +``` + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +uv run pytest tests/unit/runtime/test_workflow_service.py tests/unit/runtime/test_graph_mutation_contracts.py tests/unit/runtime/test_graph_worker_identity.py tests/unit/runtime/test_task_execution_repository.py tests/unit/runtime/test_inngest_criterion_executor.py tests/unit/runtime/test_dynamic_task_evaluation_mapping.py -q +``` + +Expected: PASS. + +## Task 6: Move Read Models, Communication, Context, And Resources + +**Files:** +- Move: `core/runtime/read_models/{runs,run_snapshot,experiments,cohorts,resources,errors}.py` -> `core/application/read_models/` +- Split: communication DTOs from `read_models/models.py` -> `core/application/communication/models.py` +- Move: `core/runtime/read_models/communication.py` -> `core/application/communication/service.py` +- Move: remaining read model DTOs -> `core/application/read_models/models.py` +- Move: `core/runtime/context_events.py` -> `core/application/context/events.py` +- Move: `core/runtime/output_extraction.py` -> `core/application/context/output_extraction.py` +- Split: `core/runtime/resources.py` -> `core/application/resources/models.py` and `core/application/resources/repository.py` +- Test: dashboard/read-model/context/resource tests + +- [ ] **Step 1: Move read models** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/application/read_models +mv ergon_core/ergon_core/core/runtime/read_models/runs.py ergon_core/ergon_core/core/application/read_models/runs.py +mv ergon_core/ergon_core/core/runtime/read_models/run_snapshot.py ergon_core/ergon_core/core/application/read_models/run_snapshot.py +mv ergon_core/ergon_core/core/runtime/read_models/experiments.py ergon_core/ergon_core/core/application/read_models/experiments.py +mv ergon_core/ergon_core/core/runtime/read_models/cohorts.py ergon_core/ergon_core/core/application/read_models/cohorts.py +mv ergon_core/ergon_core/core/runtime/read_models/resources.py ergon_core/ergon_core/core/application/read_models/resources.py +mv ergon_core/ergon_core/core/runtime/read_models/errors.py ergon_core/ergon_core/core/application/read_models/errors.py +mv ergon_core/ergon_core/core/runtime/read_models/models.py ergon_core/ergon_core/core/application/read_models/models.py +touch ergon_core/ergon_core/core/application/read_models/__init__.py +``` + +- [ ] **Step 2: Move communication domain** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/application/communication +mv ergon_core/ergon_core/core/runtime/read_models/communication.py ergon_core/ergon_core/core/application/communication/service.py +touch ergon_core/ergon_core/core/application/communication/__init__.py +touch ergon_core/ergon_core/core/application/communication/errors.py +touch ergon_core/ergon_core/core/application/communication/models.py +rm ergon_core/ergon_core/core/runtime/read_models/__init__.py +rmdir ergon_core/ergon_core/core/runtime/read_models +``` + +Move `RunCommunicationMessageDto` and `RunCommunicationThreadDto` from `application/read_models/models.py` into `application/communication/models.py`, then update imports to read from `ergon_core.core.application.communication.models`. + +- [ ] **Step 3: Move context domain** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/application/context +mv ergon_core/ergon_core/core/runtime/context_events.py ergon_core/ergon_core/core/application/context/events.py +mv ergon_core/ergon_core/core/runtime/output_extraction.py ergon_core/ergon_core/core/application/context/output_extraction.py +touch ergon_core/ergon_core/core/application/context/__init__.py +``` + +- [ ] **Step 4: Split resources module** + +Create `ergon_core/ergon_core/core/application/resources/models.py` with `RunResourceView`. + +Create `ergon_core/ergon_core/core/application/resources/repository.py` with `RunResourceRepository`. + +Delete `ergon_core/ergon_core/core/runtime/resources.py`. + +Use this package initializer: + +```python +from ergon_core.core.application.resources.models import RunResourceView +from ergon_core.core.application.resources.repository import RunResourceRepository + +__all__ = ["RunResourceRepository", "RunResourceView"] +``` + +- [ ] **Step 5: Bulk update imports** + +Run: + +```bash +python - <<'PY' +from pathlib import Path + +replacements = { + "ergon_core.core.runtime.read_models.communication": "ergon_core.core.application.communication.service", + "ergon_core.core.runtime.read_models": "ergon_core.core.application.read_models", + "ergon_core.core.runtime.context_events": "ergon_core.core.application.context.events", + "ergon_core.core.runtime.output_extraction": "ergon_core.core.application.context.output_extraction", + "ergon_core.core.runtime.resources": "ergon_core.core.application.resources", +} + +for root in [Path("ergon_core"), Path("ergon_cli"), Path("ergon_builtins"), Path("tests")]: + for path in root.rglob("*.py"): + text = path.read_text() + new = text + for old, replacement in replacements.items(): + new = new.replace(old, replacement) + if new != text: + path.write_text(new) +PY +``` + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +uv run pytest tests/unit/dashboard/test_communication_threads.py tests/unit/runtime/test_communication_service.py tests/unit/persistence/test_context_event_repository.py tests/unit/runtime/test_persist_outputs_resources.py tests/unit/runtime/test_experiment_read_service.py tests/unit/runtime/test_cohort_service.py -q +``` + +Expected: PASS. + +## Task 7: Split Inngest Handlers Into Application Jobs And Infrastructure Adapters + +**Files:** +- Move semantic logic: `core/runtime/inngest/{handler files}.py` -> `core/application/jobs/{handler files}.py` +- Create: `core/application/jobs/models.py` +- Create thin adapters: `core/infrastructure/inngest/handlers/{handler files}.py` +- Move: `runtime/inngest/client.py` -> `infrastructure/inngest/client.py` +- Move: `runtime/inngest/registry.py` -> `infrastructure/inngest/registry.py` +- Move: `runtime/inngest/contracts.py` -> `infrastructure/inngest/contracts.py` +- Move: `runtime/inngest/errors.py` -> `infrastructure/inngest/errors.py` +- Test: Inngest/runtime unit tests and import registry tests + +- [ ] **Step 1: Move infrastructure primitives** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/infrastructure/inngest/handlers +mv ergon_core/ergon_core/core/runtime/inngest/client.py ergon_core/ergon_core/core/infrastructure/inngest/client.py +mv ergon_core/ergon_core/core/runtime/inngest/registry.py ergon_core/ergon_core/core/infrastructure/inngest/registry.py +mv ergon_core/ergon_core/core/runtime/inngest/contracts.py ergon_core/ergon_core/core/infrastructure/inngest/contracts.py +mv ergon_core/ergon_core/core/runtime/inngest/errors.py ergon_core/ergon_core/core/infrastructure/inngest/errors.py +touch ergon_core/ergon_core/core/infrastructure/__init__.py +touch ergon_core/ergon_core/core/infrastructure/inngest/__init__.py +touch ergon_core/ergon_core/core/infrastructure/inngest/handlers/__init__.py +``` + +- [ ] **Step 2: Move handler semantics into jobs** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/application/jobs +for name in cancel_orphan_subtasks check_evaluators cleanup_cancelled_task complete_workflow evaluate_task_run execute_task fail_workflow persist_outputs propagate_execution run_cleanup sandbox_setup start_workflow worker_execute; do + mv "ergon_core/ergon_core/core/runtime/inngest/${name}.py" "ergon_core/ergon_core/core/application/jobs/${name}.py" +done +touch ergon_core/ergon_core/core/application/jobs/__init__.py +rm ergon_core/ergon_core/core/runtime/inngest/__init__.py 2>/dev/null || true +rmdir ergon_core/ergon_core/core/runtime/inngest +``` + +- [ ] **Step 3: Add thin adapters** + +For each moved job, remove the Inngest decorator from the application job file and expose an async `run__job(...)` function that contains the semantic behavior. The infrastructure handler owns the `@inngest_client.create_function(...)` decorator and delegates to the application job. + +For `worker_execute`, transform `core/application/jobs/worker_execute.py` so it starts like this: + +```python +"""Application job for worker execution.""" + +import logging +import traceback +from datetime import UTC, datetime + +from ergon_core.api.benchmark import EmptyTaskPayload, Task +from ergon_core.api.worker import WorkerContext +from ergon_core.core.application.context.events import ContextEventService +from ergon_core.core.application.experiments.repository import DefinitionRepository +from ergon_core.core.application.jobs.models import WorkerExecuteJobRequest +from ergon_core.core.application.jobs.models import WorkerExecuteJobResult +from ergon_core.core.domain.generation.context_parts import ContextPartChunk +from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.infrastructure.inngest.errors import RegistryLookupError +from ergon_core.core.infrastructure.tracing import ( + CompletedSpan, + get_trace_sink, + worker_execute_context, +) +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +async def run_worker_execute_job(payload: WorkerExecuteJobRequest) -> WorkerExecuteJobResult: + from ergon_builtins.registry import BENCHMARKS, WORKERS + + # Move the current body of worker_execute_fn here, replacing ctx.event.data + # with the typed payload argument. +``` + +Create `core/application/jobs/models.py` for job request/result aliases imported from Inngest contracts during the first migration: + +```python +"""Application job contracts. + +These mirror external Inngest event contracts during the migration so job logic +can be called independently of Inngest decorators. +""" + +from ergon_core.core.infrastructure.inngest.contracts import ( + CleanupCancelledTaskRequest, + CleanupCancelledTaskResult, + CompleteWorkflowRequest, + CompleteWorkflowResult, + EvaluateTaskRequest, + EvaluateTaskResult, + ExecuteTaskRequest, + ExecuteTaskResult, + PropagateExecutionRequest, + PropagateExecutionResult, + SandboxSetupRequest, + SandboxSetupResult, + StartWorkflowRequest, + StartWorkflowResult, + WorkerExecuteRequest as WorkerExecuteJobRequest, + WorkerExecuteResult as WorkerExecuteJobResult, +) + +__all__ = [ + "CleanupCancelledTaskRequest", + "CleanupCancelledTaskResult", + "CompleteWorkflowRequest", + "CompleteWorkflowResult", + "EvaluateTaskRequest", + "EvaluateTaskResult", + "ExecuteTaskRequest", + "ExecuteTaskResult", + "PropagateExecutionRequest", + "PropagateExecutionResult", + "SandboxSetupRequest", + "SandboxSetupResult", + "StartWorkflowRequest", + "StartWorkflowResult", + "WorkerExecuteJobRequest", + "WorkerExecuteJobResult", +] +``` + +Create `core/infrastructure/inngest/handlers/worker_execute.py` as the thin adapter: + +```python +"""Inngest adapter for worker execution.""" + +import inngest + +from ergon_core.core.application.jobs.worker_execute import run_worker_execute_job +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.infrastructure.inngest.contracts import ( + WorkerExecuteRequest, + WorkerExecuteResult, +) + + +@inngest_client.create_function( + fn_id="worker-execute", + trigger=inngest.TriggerEvent(event="task/worker-execute"), + retries=0, + output_type=WorkerExecuteResult, +) +async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: + return await run_worker_execute_job(WorkerExecuteRequest.model_validate(ctx.event.data)) + +__all__ = ["worker_execute_fn"] +``` + +Use the same pattern for every handler: `application/jobs/.py` exports `run__job`, and `infrastructure/inngest/handlers/.py` owns the decorator and event parsing. Preserve the existing `fn_id`, trigger event, retry policy, and output type from the original handler. + +- [ ] **Step 4: Update registry imports** + +In `core/infrastructure/inngest/registry.py`, import handler modules from `ergon_core.core.infrastructure.inngest.handlers`. + +If the registry currently imports function objects from handler modules, keep the same object names and only change module paths. + +- [ ] **Step 5: Bulk update imports** + +Run: + +```bash +python - <<'PY' +from pathlib import Path + +replacements = { + "ergon_core.core.runtime.inngest.client": "ergon_core.core.infrastructure.inngest.client", + "ergon_core.core.runtime.inngest.registry": "ergon_core.core.infrastructure.inngest.registry", + "ergon_core.core.runtime.inngest.contracts": "ergon_core.core.infrastructure.inngest.contracts", + "ergon_core.core.runtime.inngest.errors": "ergon_core.core.infrastructure.inngest.errors", + "ergon_core.core.runtime.inngest.": "ergon_core.core.application.jobs.", +} + +for root in [Path("ergon_core"), Path("ergon_cli"), Path("ergon_builtins"), Path("tests")]: + for path in root.rglob("*.py"): + text = path.read_text() + new = text + for old, replacement in replacements.items(): + new = new.replace(old, replacement) + if new != text: + path.write_text(new) +PY +``` + +After the script, inspect `core/infrastructure/inngest/registry.py` and adapter files. Registry imports should point to `infrastructure.inngest.handlers`, not `application.jobs`. + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +uv run pytest tests/unit/runtime/test_child_function_payloads.py tests/unit/runtime/test_inngest_criterion_executor.py tests/unit/runtime/test_import_boundaries.py tests/unit/registry/test_react_factories.py -q +``` + +Expected: PASS. + +## Task 8: Move Infrastructure Packages + +**Files:** +- Move: `core/sandbox/*` -> `core/infrastructure/sandbox/*` +- Move: `core/dashboard/*` -> `core/infrastructure/dashboard/*` +- Move: `core/runtime/tracing/*` -> `core/infrastructure/tracing/*` +- Move: `core/runtime/dependencies.py` -> `core/infrastructure/dependencies.py` +- Modify: imports across source and tests +- Test: dashboard, sandbox, tracing, dependency tests + +- [ ] **Step 1: Move sandbox** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/infrastructure/sandbox +mv ergon_core/ergon_core/core/sandbox/*.py ergon_core/ergon_core/core/infrastructure/sandbox/ +rmdir ergon_core/ergon_core/core/sandbox +``` + +- [ ] **Step 2: Move dashboard** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/infrastructure/dashboard +mv ergon_core/ergon_core/core/dashboard/*.py ergon_core/ergon_core/core/infrastructure/dashboard/ +rmdir ergon_core/ergon_core/core/dashboard +``` + +- [ ] **Step 3: Move tracing and dependency probe** + +Run: + +```bash +mkdir -p ergon_core/ergon_core/core/infrastructure/tracing +mv ergon_core/ergon_core/core/runtime/tracing/*.py ergon_core/ergon_core/core/infrastructure/tracing/ +rmdir ergon_core/ergon_core/core/runtime/tracing +mv ergon_core/ergon_core/core/runtime/dependencies.py ergon_core/ergon_core/core/infrastructure/dependencies.py +``` + +- [ ] **Step 4: Bulk update imports** + +Run: + +```bash +python - <<'PY' +from pathlib import Path + +replacements = { + "ergon_core.core.sandbox": "ergon_core.core.infrastructure.sandbox", + "ergon_core.core.dashboard": "ergon_core.core.infrastructure.dashboard", + "ergon_core.core.runtime.tracing": "ergon_core.core.infrastructure.tracing", + "ergon_core.core.runtime.dependencies": "ergon_core.core.infrastructure.dependencies", +} + +for root in [Path("ergon_core"), Path("ergon_cli"), Path("ergon_builtins"), Path("tests")]: + for path in root.rglob("*.py"): + text = path.read_text() + new = text + for old, replacement in replacements.items(): + new = new.replace(old, replacement) + if new != text: + path.write_text(new) +PY +``` + +- [ ] **Step 5: Run focused tests** + +Run: + +```bash +uv run pytest tests/unit/dashboard/test_event_contract_types.py tests/unit/runtime/test_sandbox_setup_explicit_slug.py tests/unit/benchmarks/test_swebench_sandbox_manager.py tests/unit/state/test_benchmark_contract.py -q +``` + +Expected: PASS. + +## Task 9: Move Application Events, Remove Runtime Root, And Add Durable Import Direction Guards + +**Files:** +- Move: `ergon_core/ergon_core/core/runtime/events/*` -> `ergon_core/ergon_core/core/application/events/*` +- Delete: `ergon_core/ergon_core/core/runtime/` +- Modify: `tests/unit/architecture/test_core_schema_sources.py` +- Test: architecture suite + +- [ ] **Step 1: Delete empty runtime root** + +First move the remaining semantic event contracts out of runtime: + +```bash +mkdir -p ergon_core/ergon_core/core/application/events +mv ergon_core/ergon_core/core/runtime/events/*.py ergon_core/ergon_core/core/application/events/ +rmdir ergon_core/ergon_core/core/runtime/events +``` + +Then update imports: + +```bash +python - <<'PY' +from pathlib import Path + +for root in [Path("ergon_core"), Path("ergon_cli"), Path("ergon_builtins"), Path("tests")]: + for path in root.rglob("*.py"): + text = path.read_text() + new = text.replace( + "ergon_core.core.runtime.events", + "ergon_core.core.application.events", + ) + if new != text: + path.write_text(new) +PY +``` + +Now delete the empty runtime root: + +Run: + +```bash +rmdir ergon_core/ergon_core/core/runtime +``` + +Expected: command succeeds because all runtime subpackages and files have moved. + +- [ ] **Step 2: Add durable root guard** + +Append to `tests/unit/architecture/test_core_schema_sources.py`: + +```python +def test_core_uses_hybrid_domain_layout_roots() -> None: + core = ROOT / "ergon_core/ergon_core/core" + + expected_dirs = { + "application", + "domain", + "infrastructure", + "persistence", + "rest_api", + "rl", + "shared", + } + actual_dirs = {path.name for path in core.iterdir() if path.is_dir() and path.name != "__pycache__"} + + assert expected_dirs <= actual_dirs + assert "runtime" not in actual_dirs + assert "api" not in actual_dirs + assert "definitions" not in actual_dirs + assert "composition" not in actual_dirs + assert "sandbox" not in actual_dirs + assert "dashboard" not in actual_dirs +``` + +- [ ] **Step 3: Add import direction guard** + +Append to `tests/unit/architecture/test_core_schema_sources.py`: + +```python +def test_core_hybrid_layout_import_directions() -> None: + forbidden_imports = { + "domain": ( + "ergon_core.core.application", + "ergon_core.core.persistence", + "ergon_core.core.infrastructure", + "ergon_core.core.rest_api", + ), + "persistence": ( + "ergon_core.core.application", + "ergon_core.core.infrastructure", + "ergon_core.core.rest_api", + ), + "application": ( + "ergon_core.core.rest_api", + "ergon_core.core.infrastructure.inngest.handlers", + ), + } + + offenders: list[str] = [] + for root_name, snippets in forbidden_imports.items(): + root = ROOT / "ergon_core/ergon_core/core" / root_name + for path in root.rglob("*.py"): + text = path.read_text() + for snippet in snippets: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} imports {snippet}") + + assert offenders == [] +``` + +- [ ] **Step 4: Add job adapter split guard** + +Append to `tests/unit/architecture/test_core_schema_sources.py`: + +```python +def test_application_jobs_do_not_own_inngest_decorators() -> None: + jobs_root = ROOT / "ergon_core/ergon_core/core/application/jobs" + offenders: list[str] = [] + + for path in jobs_root.rglob("*.py"): + text = path.read_text() + if "@inngest_client.create_function" in text or "import inngest" in text: + offenders.append(str(path.relative_to(ROOT))) + if "ergon_core.core.infrastructure.inngest.handlers" in text: + offenders.append(str(path.relative_to(ROOT))) + + assert offenders == [] +``` + +- [ ] **Step 5: Run architecture tests** + +Run: + +```bash +uv run pytest tests/unit/architecture -q +``` + +Expected: PASS except the temporary exact-layout test may still fail if additional unexpected files exist. If it fails, inspect the exact `unexpected` list and decide whether the target doc should include those files or the files should move/delete. + +## Task 10: Finalize Exact Layout, Delete Temporary Test + +**Files:** +- Delete: `tests/unit/architecture/test_core_hybrid_layout_temporary.py` +- Modify: `docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout.md` if any final file names changed during implementation +- Test: architecture suite and focused regression suite + +- [ ] **Step 1: Run temporary exact-layout test one last time** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_core_hybrid_layout_temporary.py -q +``` + +Expected: PASS. This proves the temporary exact target was achieved before deleting the brittle guard. + +- [ ] **Step 2: Delete the temporary test** + +Run: + +```bash +rm tests/unit/architecture/test_core_hybrid_layout_temporary.py +``` + +- [ ] **Step 3: Run architecture and focused regression tests** + +Run: + +```bash +uv run pytest tests/unit/architecture tests/unit/runtime/test_workflow_service.py tests/unit/runtime/test_task_execution_repository.py tests/unit/runtime/test_inngest_criterion_executor.py tests/unit/dashboard/test_communication_threads.py tests/unit/cli/test_experiment_cli.py tests/unit/benchmarks/test_swebench_sandbox_manager.py -q +``` + +Expected: PASS. + +- [ ] **Step 4: Run ruff on moved source and tests** + +Run: + +```bash +uv run ruff check ergon_core ergon_cli ergon_builtins tests/unit/architecture +``` + +Expected: PASS. + +## Task 11: Broad Verification + +**Files:** +- Modify: none unless tests reveal missed imports +- Test: broad unit/integration suite as time permits + +- [ ] **Step 1: Search for stale paths** + +Run: + +```bash +rg "ergon_core\\.core\\.(runtime|api|definitions|composition|sandbox|dashboard)|core/runtime|core/api|core/definitions|core/composition|core/sandbox|core/dashboard" ergon_core ergon_cli ergon_builtins tests docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout.md +``` + +Expected: no stale code imports. Documentation may mention old paths only in current-to-target move maps. + +- [ ] **Step 2: Run broad unit tests** + +Run: + +```bash +uv run pytest tests/unit -q +``` + +Expected: PASS, or failures only from known environment import-resolution issues. Fix any migration-related import failures. + +- [ ] **Step 3: Run targeted integration tests** + +Run: + +```bash +uv run pytest tests/integration/propagation tests/integration/restart tests/integration/smokes -q +``` + +Expected: PASS, or failures clearly unrelated to package movement. + +## Self-Review Checklist + +- Every moved package has a target path in the plan. +- The temporary exact folder test is added first and deleted in the final cleanup. +- `core/rl` remains top-level. +- `core/rest_api` is distinct from public `ergon_core.api`. +- Inngest semantic jobs land in `application/jobs`; adapters land in `infrastructure/inngest/handlers`. +- No compatibility aliases are required by the plan. +- No git commits are required by the plan. diff --git a/docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout.md b/docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout.md new file mode 100644 index 00000000..685b2316 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-core-hybrid-domain-layout.md @@ -0,0 +1,584 @@ +# Core Hybrid Domain Layout + +This documents the implemented hybrid layout for `ergon_core.core`: hard +technical layers stay visible (`rest_api`, `persistence`, `infrastructure`), +while product/application concepts live in explicit clusters under +`core/application`. + +The goal is not "everything is domain-first". The goal is that a new contributor +can answer three questions quickly: + +1. Where do use cases live? +2. Where do SQL/storage rows live? +3. Where do transport/infrastructure adapters live? + +## Implemented Top-Level Shape + +```text +ergon_core/ergon_core/core/ + __init__.py + + rest_api/ + # FastAPI / HTTP transport only. + # Named rest_api to avoid confusion with the public authoring API + # under ergon_core.api. + # Should import application services and read models, not own domain logic. + __init__.py + app.py + cohorts.py + experiments.py + rollouts.py + runs.py + test_harness.py + + application/ + # Product use cases and domain-aware repositories. + # This replaces the current "runtime as second root" feeling. + + experiments/ + # Define experiments, persist authored definitions, launch experiment runs. + # Implemented from: + # - core/definitions/service.py + # - core/definitions/persistence.py + # - core/definitions/repository.py + # - core/definitions/schemas.py + # - runtime/workflows/launch.py + __init__.py + service.py + models.py + repository.py + definition_writer.py + launch.py + + workflows/ + # Run/workflow lifecycle after a definition exists. + # Implemented from: + # - runtime/workflows/service.py + # - runtime/workflows/orchestration.py + # - runtime/workflows/runs.py + # - runtime/workflows/models.py + # - runtime/workflows/errors.py + service.py + orchestration.py + runs.py + models.py + errors.py + + graph/ + # Runtime graph mutations, traversal, lookup, and propagation. + # Implemented from: + # - runtime/graph/* + repository.py + propagation.py + traversal.py + lookup.py + models.py + errors.py + + tasks/ + # Task execution lifecycle and task execution repository. + # Implemented from: + # - runtime/tasks/* + __init__.py + service.py + execution.py + management.py + inspection.py + cleanup.py + repository.py + models.py + errors.py + + evaluation/ + # Evaluation dispatch, criterion runtime, scoring, persistence use cases. + # Implemented from: + # - runtime/evaluation/* + service.py + executors.py + inngest_executor.py + criterion_runtime.py + scoring.py + protocols.py + models.py + errors.py + + read_models/ + # Query-side DTO assembly for UI/API surfaces. + # Implemented from: + # - runtime/read_models/runs.py + # - runtime/read_models/run_snapshot.py + # - runtime/read_models/experiments.py + # - runtime/read_models/cohorts.py + # - runtime/read_models/resources.py + # - runtime/read_models/models.py + # - runtime/read_models/errors.py + __init__.py + runs.py + run_snapshot.py + experiments.py + cohorts.py + resources.py + models.py + errors.py + + communication/ + # Agent-to-agent communication is its own product domain. + # Do not fold this into run read models. + # Implemented from: + # - runtime/read_models/communication.py + # - relevant communication DTOs currently in runtime/read_models/models.py + __init__.py + service.py + models.py + errors.py + + context/ + # Worker context event stream and output extraction. + # Implemented from: + # - runtime/context_events.py + # - runtime/output_extraction.py + __init__.py + events.py + output_extraction.py + + jobs/ + # Core semantic workflows currently implemented inside Inngest handlers. + # These are background job use cases. Inngest should call them, not own + # their branching, persistence, and orchestration rules. + # Implemented from: + # - runtime/inngest/{handler files}.py, after extracting adapter details. + cancel_orphan_subtasks.py + check_evaluators.py + cleanup_cancelled_task.py + complete_workflow.py + evaluate_task_run.py + execute_task.py + fail_workflow.py + persist_outputs.py + propagate_execution.py + run_cleanup.py + sandbox_setup.py + start_workflow.py + worker_execute.py + models.py + + resources/ + # Run resource append/query use cases that are not just API presentation. + # Implemented from: + # - runtime/resources.py + # - sandbox/resource_publisher.py may depend on repository here + __init__.py + repository.py + models.py + + events/ + # Product/application event contracts used by jobs, adapters, and + # dashboard emission. The adapter layer may send these through Inngest, + # but it should not own their semantic schemas. + # Implemented from: + # - runtime/events/* + __init__.py + base.py + task_events.py + infrastructure_events.py + + domain/ + # Pure-ish domain objects that should not know about DB sessions, + # Inngest, FastAPI, or dashboard emission. + + experiments/ + # Authoring/composition objects. + # Implemented from: + # - core/composition/* + __init__.py + experiment.py + handles.py + worker_spec.py + validation.py + + generation/ + # Context stream and generation transcript primitives. + # Implemented from: + # - core/generation.py + context_parts.py + + persistence/ + # SQLModel rows, DB/session helpers, and storage-only repositories. + # Should not own product workflows or read-model assembly. + + shared/ + db.py + enums.py + ids.py + types.py + + definitions/ + models.py + + telemetry/ + models.py + repositories.py + evaluation_summary.py + + graph/ + models.py + status_conventions.py + + context/ + models.py + event_payloads.py + + saved_specs/ + models.py + + infrastructure/ + # External adapters and operational plumbing. + # Infrastructure calls application services; application should not import + # concrete infrastructure except through deliberate adapter seams. + + inngest/ + # Inngest client, contracts, registry, and thin function adapters. + # Implemented from: + # - runtime/inngest/client.py + # - runtime/inngest/registry.py + # - runtime/inngest/contracts.py + # - runtime/inngest/errors.py + # - runtime/inngest/{handler files}.py after semantic logic moves to + # application/jobs. + client.py + registry.py + contracts.py + errors.py + + handlers/ + cancel_orphan_subtasks.py + check_evaluators.py + cleanup_cancelled_task.py + complete_workflow.py + evaluate_task_run.py + execute_task.py + fail_workflow.py + persist_outputs.py + propagate_execution.py + run_cleanup.py + sandbox_setup.py + start_workflow.py + worker_execute.py + + sandbox/ + # E2B/local sandbox managers and sandbox instrumentation. + # Implemented from: + # - core/sandbox/* + __init__.py + manager.py + lifecycle.py + resource_publisher.py + instrumentation.py + event_sink.py + errors.py + utils.py + + dashboard/ + # Dashboard event emission/integration. + # Implemented from: + # - core/dashboard/* + __init__.py + emitter.py + provider.py + event_contracts.py + + tracing/ + # Tracing/OpenTelemetry adapters and sinks. + # Implemented from: + # - runtime/tracing/* + __init__.py + attributes.py + contexts.py + ids.py + noop.py + otel.py + sinks.py + types.py + + dependencies.py + + rl/ + # Keep as a separate bounded context for now. + # Rollouts, rewards, extraction, checkpointing, and vLLM management cut + # across product use cases and are closer to training/research machinery + # than ordinary application services. + __init__.py + rollout_service.py + eval_runner.py + extraction.py + rewards.py + checkpoint.py + rollout_types.py + vllm_manager.py + + shared/ + # Small cross-cutting primitives. Keep this boring and sparse. + json_types.py + settings.py + utils.py +``` + +## Clusters And Ownership Rules + +### `core/application` + +Application packages own use cases. They can import: + +- `core/domain` +- `core/persistence` +- `core/shared` + +They should not import: + +- `core/rest_api` +- Inngest function modules +- FastAPI router modules + +`application` is where the former `runtime` domains landed. The important rename +is conceptual: the old `runtime` package mixed use cases, adapters, and +operational helpers, while `application` now means "use cases over the persisted +product model." + +### `core/domain` + +Domain packages own objects that should be understandable without infrastructure: + +- experiment composition +- worker specs +- definition handles +- context/generation primitives + +These modules should not create DB sessions, emit dashboard events, or know about +Inngest. They may validate invariants and expose plain objects. + +### `core/persistence` + +Persistence owns rows and storage helpers. It should not own product decisions. + +Examples that should stay here: + +- SQLModel row classes +- session helpers +- enum/storage types +- storage-only repositories + +Examples that should not live here: + +- query-bag application workflows +- evaluation summary refresh orchestration +- context event sequencing logic +- run snapshot assembly + +### `core/infrastructure` + +Infrastructure owns adapters: + +- Inngest client, registry, contracts, and thin function adapters +- sandbox manager/resource publisher +- dashboard emitter +- tracing adapters +- package dependency probes + +Infrastructure modules can call application services. They should not become +the canonical home for business rules. Inngest handlers are split so core +semantic logic lives in `application/jobs`, while the Inngest-decorated shell +remains under `infrastructure/inngest/handlers`. + +### `core/rest_api` + +`core/rest_api` is the HTTP layer. The explicit name keeps it visually separate +from `ergon_core.api`, which is the public authoring/types API for builtins, +CLI, and students. It should be thin: + +- validate/deserialize transport requests +- call application services/read models +- map missing resources to HTTP errors + +It should not define reusable domain DTOs just because the frontend consumes +them. Those belong in `application/read_models` or the relevant application +domain. + +## Implemented Move Map + +```text +core/definitions/service.py + -> core/application/experiments/service.py + +core/definitions/schemas.py + -> core/application/experiments/models.py + +core/definitions/repository.py + -> core/application/experiments/repository.py + +core/definitions/persistence.py + -> core/application/experiments/definition_writer.py + +core/composition/* + -> core/domain/experiments/* + +core/runtime/workflows/* + -> core/application/workflows/* + except runtime/workflows/launch.py + -> core/application/experiments/launch.py + +core/runtime/graph/* + -> core/application/graph/* + +core/runtime/tasks/* + -> core/application/tasks/* + +core/runtime/evaluation/* + -> core/application/evaluation/* + +core/runtime/read_models/runs.py +core/runtime/read_models/run_snapshot.py +core/runtime/read_models/experiments.py +core/runtime/read_models/cohorts.py +core/runtime/read_models/resources.py +core/runtime/read_models/errors.py +core/runtime/read_models/models.py + -> core/application/read_models/* + +core/runtime/read_models/communication.py + -> core/application/communication/service.py + +communication DTOs from core/runtime/read_models/models.py + -> core/application/communication/models.py + +core/runtime/context_events.py + -> core/application/context/events.py + +core/runtime/output_extraction.py + -> core/application/context/output_extraction.py + +core/runtime/resources.py + -> core/application/resources/models.py + -> core/application/resources/repository.py + +core/runtime/events/* + -> core/application/events/* + +core/rl/* + -> core/rl/* + # Keep in place for now as a separate bounded context. + +core/runtime/inngest/client.py +core/runtime/inngest/registry.py +core/runtime/inngest/contracts.py +core/runtime/inngest/errors.py + -> core/infrastructure/inngest/* + +core/runtime/inngest/{handler files}.py + -> core/application/jobs/{handler files}.py + -> core/infrastructure/inngest/handlers/{handler files}.py + # Split each handler: semantic background job into application/jobs, + # Inngest decorator/event adapter into infrastructure/inngest/handlers. + +core/sandbox/* + -> core/infrastructure/sandbox/* + +core/dashboard/* + -> core/infrastructure/dashboard/* + +core/runtime/tracing/* + -> core/infrastructure/tracing/* + +core/runtime/dependencies.py + -> core/infrastructure/dependencies.py + +core/generation.py + -> core/domain/generation/context_parts.py + +core/json_types.py +core/settings.py +core/utils.py + -> core/shared/* +``` + +## Deleted Legacy Paths + +```text +core/runtime/ + # Deleted after all subpackages moved. + +core/definitions/ + # Deleted after experiment lifecycle files moved to application/experiments. + +core/composition/ + # Deleted after pure domain objects moved to domain/experiments. + +core/sandbox/ +core/dashboard/ + # Deleted after infrastructure moved. + +core/generation.py +core/json_types.py +core/settings.py +core/utils.py + # Deleted after shared/domain moves. +``` + +## Import Direction Guardrails + +```text +api -> application -> domain +api -> application -> persistence +api -> shared + +infrastructure -> application +infrastructure -> domain +infrastructure -> persistence +infrastructure -> shared + +application -> domain +application -> persistence +application -> shared + +persistence -> shared +persistence -> domain/generation only if row payload parsing requires typed context parts + +domain -> shared +``` + +Forbidden directions: + +```text +domain -> application +domain -> persistence +domain -> infrastructure +domain -> rest_api + +persistence -> application +persistence -> infrastructure +persistence -> rest_api + +application -> rest_api +application -> infrastructure/inngest/handlers +``` + +## Resolved Decisions + +1. This intentionally keeps `communication` separate from run read models. It is + a product domain for agents communicating with each other. +2. `read_models` stays as a query-side application cluster instead of being + split into every domain. That reduces churn while keeping REST + routers thin. +3. `application/jobs` keeps the core semantics of externally-triggered + background workflows visible. `infrastructure/inngest/handlers` should be + thin wrappers around those use cases. +4. `persistence` remains a visible top-level layer because hiding SQL rows + inside product domains would make storage contracts harder to + audit. +5. Old-path compatibility aliases are intentionally avoided. Bulk import renames + keep the finalized package structure explicit. +6. `domain/generation/context_parts.py` remains the name for generation context + primitives. +7. Dashboard emission stays under `infrastructure/dashboard`, while product + event contracts live under `application/events`. +8. `core/rl` remains its own bounded context instead of being renamed to + `core/learning`. diff --git a/docs/superpowers/plans/2026-04-28-ergon-builtins-rebuild-structure.md b/docs/superpowers/plans/2026-04-28-ergon-builtins-rebuild-structure.md new file mode 100644 index 00000000..167b0b20 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-ergon-builtins-rebuild-structure.md @@ -0,0 +1,709 @@ +# Ergon Built-ins Rebuild Structure + +This document lays out the target shape for `ergon_builtins` after the Ergon core public API cleanup. It assumes the core authoring API from `2026-04-28-public-api-target-structure.md`: + +- `Benchmark`, `Task`, `BenchmarkRequirements` +- `Worker`, `WorkerContext`, `WorkerOutput` +- `Criterion`, `CriterionContext`, `CriterionOutcome`, `ScoreScale` +- `Rubric`, `TaskEvaluationResult` +- advanced `Evaluator` only when a fixed `Rubric` is not expressive enough + +The key design rule is that built-ins should be normal public API consumers. The CLI and runtime should discover built-ins through typed registries and service facades, not by importing benchmark internals or rebuilding object graphs by hand. + +## Goals + +- Keep benchmark authoring code small, public-API-first, and easy to copy for external benchmark authors. +- Keep sandbox, dataset loading, and optional dependency code inside benchmark-owned packages. +- Keep the registry as the stable integration boundary for CLI discovery, experiment definition, run launch, and Inngest execution. +- Keep benchmark slugs separate from runtime choices: the CLI must pass worker, evaluator, sandbox, model, and extras/dependency intent explicitly for now. +- Avoid compatibility aliases for renamed public concepts during the coordinated rebuild. + +## Runtime Integration Model + +```mermaid +flowchart TD + accTitle: Builtins Runtime Flow + accDescr: Built-in benchmark, worker, and evaluator slugs flow from the registry through CLI services, persisted definitions, run records, and Inngest execution. + + registry["ergon_builtins.registry
slugs and factories"] + cli["CLI commands
define, run, list"] + facades["core runtime services
experiment, cohort, run"] + experiment["ExperimentRecord
selected samples and explicit choices"] + definition["Workflow definition
task graph and type slugs"] + run["RunRecord
instance key, worker team, evaluator slug"] + inngest["Inngest runtime
worker and evaluator execution"] + + registry --> cli + cli --> facades + facades --> experiment + facades --> definition + facades --> run + definition --> inngest + run --> inngest + registry --> inngest +``` + +The CLI path should be slug-driven: + +1. Validate the explicit `benchmark_slug`, `worker_slug`, `evaluator_slug`, and `sandbox_slug` against `ergon_builtins.registry`. +2. Ask a core service facade to define or launch the experiment. +3. Persist only durable identifiers and slugs in `ExperimentRecord`, workflow definitions, and `RunRecord`. +4. Rehydrate live workers, criteria, rubrics, and sandbox managers from registries at runtime. + +## Proposed Package Tree + +```text +ergon_builtins/ + ergon_builtins/ + __init__.py + + registry.py + # merged public discovery surface + # imports registry_core and optional registries + + registry_core.py + # always-importable built-ins with no [data] dependency + # exports BENCHMARKS, WORKERS, EVALUATORS, SANDBOX_MANAGERS, + # SANDBOX_TEMPLATES, MODEL_BACKENDS + + registry_data.py + # HuggingFace/pandas/datasets-dependent built-ins + # same export names as registry_core + + registry_local_models.py + # optional local model backends + + shared/ + __init__.py + criteria/ + code_check.py + file_check.py + llm_judge.py + sandbox_file_check.py + workers/ + react_worker.py + training_stub_worker.py + react_prompts.py + models/ + cloud_passthrough.py + openrouter_backend.py + openrouter_responses_backend.py + resolution.py + vllm_backend.py + tools/ + # reusable public worker tools only + observability/ + # event/transcript adapters used by shared workers + + benchmarks/ + minif2f/ + __init__.py + benchmark.py + task_schemas.py + worker_factory.py + prompts.py + toolkit.py + criteria.py + rubric.py + sandbox_manager.py + sandbox/ + + swebench_verified/ + __init__.py + benchmark.py + task_schemas.py + worker_factory.py + prompts.py + toolkit.py + criterion.py + rubric.py + sandbox_manager.py + sandbox_manager_support.py + sandbox/ + + gdpeval/ + __init__.py + benchmark.py + task_schemas.py + loader.py + worker_factory.py + criteria.py + rubric.py + sandbox.py + + researchrubrics/ + __init__.py + benchmark.py + vanilla.py + task_schemas.py + worker_factory.py + researcher_worker.py + workflow_cli_react_worker.py + criteria.py + judge_criterion.py + rubric.py + sandbox_manager.py +``` + +### Package Boundary Rules + +- Benchmark packages own their task payload schemas, dataset loaders, sandbox/toolkit wiring, benchmark-specific criteria, and default rubric. +- `shared/` contains reusable primitives that do not know about one benchmark's payload schema. +- Registered worker factories live next to the benchmark when they bind benchmark-specific tools or sandbox setup. +- Generic worker classes live in `shared/workers/`; benchmark packages wrap them with factories. +- Optional data dependencies stay in `registry_data.py` and data-only benchmark packages. Importing `registry_core.py` must not require `datasets`, pandas, `swebench`, or HuggingFace extras. +- CLI code should import only `ergon_builtins.registry` and core service facades. + +## Registry Contract + +The registry should continue to expose dictionaries keyed by stable slugs: + +```python +BENCHMARKS: dict[str, type[Benchmark]] +WORKERS: dict[str, WorkerFactory] +EVALUATORS: dict[str, type[Evaluator]] +SANDBOX_MANAGERS: dict[str, type[BaseSandboxManager]] +SANDBOX_TEMPLATES: dict[str, Path] +MODEL_BACKENDS: dict[str, Callable[..., ResolvedModel]] +``` + +`WorkerFactory` should remain a callable shape that the runtime can use after sandbox setup: + +```python +WorkerFactory = Callable[..., Worker] +``` + +Every registered worker factory must accept: + +```text +name: str +model: str | None +task_id: UUID +sandbox_id: str +``` + +The registry should not provide benchmark-level default profiles in this phase. Explicit beats implicit while the package structure is still moving: callers must specify the worker, evaluator, sandbox, model, and dependency extras they intend to use. + +This gives the CLI enough information to validate explicit requests for: + +- `ergon benchmark list` +- `ergon worker list` +- `ergon evaluator list` +- `ergon experiment define ` +- `ergon experiment run ` +- `ergon benchmark run ` +- onboarding/setup messages for explicitly requested extras, E2B, HuggingFace, or API keys + +## Public API Usage Rules + +Built-ins should use root imports for ordinary authoring: + +```python +from ergon_core.api import Benchmark, BenchmarkRequirements, Task +from ergon_core.api import Worker, WorkerContext, WorkerOutput +from ergon_core.api import Criterion, CriterionContext, CriterionOutcome +from ergon_core.api import Rubric, TaskEvaluationResult +``` + +Use advanced imports only where the benchmark needs dynamic criteria: + +```python +from ergon_core.api.rubric import Evaluator +``` + +Core composition types stay out of benchmark authoring files: + +- no `Experiment` imports in benchmark packages +- no `WorkerSpec` imports in benchmark packages +- no run/cohort/definition handles in benchmark packages +- no direct DB/session imports in workers, criteria, or rubrics + +## Benchmark Implementation Pattern + +Each benchmark package should follow the same high-level shape: + +```text +benchmark.py + Benchmark subclass + type_slug + task_payload_model + onboarding_deps / BenchmarkRequirements + build_instances() -> Mapping[str, Sequence[Task[Payload]]] + evaluator_requirements() + +task_schemas.py + Pydantic payload models + dataset row conversion helpers when lightweight + +worker_factory.py + factories that bind shared workers to benchmark-specific tools/sandboxes + +criteria.py / criterion.py + benchmark-specific Criterion implementations and builders + +rubric.py + Rubric or Evaluator subclass registered under a stable evaluator slug + +sandbox_manager.py / sandbox.py + benchmark-specific sandbox lifecycle and setup +``` + +`Task` construction should consistently set: + +- `task_slug`: stable dataset sample identifier +- `instance_key`: selected instance key used by experiment/run services +- `description`: worker-facing problem statement +- `evaluator_binding_keys`: usually `("default",)` unless the benchmark has multiple evaluator bindings +- `task_payload`: typed payload model containing all evaluator-only ground truth + +## MiniF2F + +### Folder + +```text +benchmarks/minif2f/ + benchmark.py + task_schemas.py + worker_factory.py + prompts.py + toolkit.py + criteria.py + rubric.py + sandbox_manager.py + sandbox/ +``` + +### Benchmark + +`MiniF2FBenchmark` should remain a public `Benchmark` implementation: + +- `type_slug = "minif2f"` +- `task_payload_model = MiniF2FTaskPayload` +- `onboarding_deps = BenchmarkRequirements(e2b=True)` +- `build_instances()` downloads or reads MiniF2F-v2c and returns one `Task` per theorem. +- `description` should include the informal statement, Lean header, and formal theorem. + +The payload should carry: + +- `name` +- `informal_statement` +- `formal_statement` +- `header` + +Ground truth proof, if available later, belongs in the payload or metadata for evaluation only, not in the worker prompt. + +### Worker + +The recommended first worker pairing is `minif2f-react`, implemented as a benchmark-owned factory around the shared ReAct worker: + +- resolve the live sandbox by `task_id` +- build `MiniF2FToolkit` +- bind Lean tools such as write file, check file, and verify proof +- pass a MiniF2F-specific system prompt +- return a `WorkerOutput` whose final answer includes the proof file path or proof text + +The factory belongs in `benchmarks/minif2f/worker_factory.py` because it knows about Lean, the sandbox manager, and the MiniF2F toolkit. + +### Criteria And Rubric + +`ProofVerificationCriterion` should use `CriterionContext` public capabilities rather than importing a concrete runtime protocol from public files. + +`MiniF2FRubric` should be a fixed `Rubric` with one proof-verification criterion: + +- score `1.0` when Lean verifies the final proof +- score partial credit for syntactically valid but incomplete proof attempts +- score `0.0` for missing or invalid proof artifacts +- return `TaskEvaluationResult` with normalized score and proof metadata + +### Required CLI Pairing + +```text +benchmark_slug: minif2f +worker_slug: minif2f-react +evaluator_slug: minif2f-rubric +sandbox_slug: minif2f +extras: none +model: explicit CLI value, e.g. openai:gpt-4o +``` + +## SWE-Bench Verified + +### Folder + +```text +benchmarks/swebench_verified/ + benchmark.py + task_schemas.py + worker_factory.py + prompts.py + toolkit.py + criterion.py + rubric.py + sandbox_manager.py + sandbox_manager_support.py + sandbox/ +``` + +### Benchmark + +`SweBenchVerifiedBenchmark` should remain the benchmark loader for `princeton-nlp/SWE-bench_Verified`: + +- `type_slug = "swebench-verified"` +- `task_payload_model = SWEBenchTaskPayload` +- `onboarding_deps = BenchmarkRequirements(e2b=True, extras=("ergon-builtins[data]",))` +- `build_instances()` returns one `Task` per SWE-Bench instance. +- the worker-facing `description` should include issue context and repo instructions, not the gold test patch. + +The payload should carry all evaluator-only data: + +- `instance_id` +- repo and base commit identifiers +- problem statement +- test patch +- FAIL_TO_PASS / PASS_TO_PASS metadata needed by the harness + +### Worker + +The recommended first worker pairing is `swebench-react`, implemented as a benchmark-owned factory around the shared ReAct worker: + +- resolve the live sandbox by `task_id` +- build `SWEBenchToolkit` +- expose shell/file/git tools scoped to `/workspace/repo` +- pass a SWE-Bench-specific system prompt +- return patch-oriented output or rely on sandbox diff extraction during evaluation + +The worker should not run the official evaluator. Its job is to modify the repo in the sandbox. + +### Criteria And Rubric + +`SWEBenchTestCriterion` should remain the atomic evaluation unit: + +- extract the agent patch from the sandbox through `CriterionContext` capabilities +- apply the gold test patch +- apply the agent patch +- run the official eval script +- parse the SWE-Bench harness report +- return `CriterionOutcome` with score `1.0` only when the instance is resolved + +`SWEBenchRubric` should live in `benchmarks/swebench_verified/rubric.py`, not in a detached global rubrics folder, because it is benchmark-specific and wraps `SWEBenchTestCriterion`. + +### Required CLI Pairing + +```text +benchmark_slug: swebench-verified +worker_slug: swebench-react +evaluator_slug: swebench-rubric +sandbox_slug: swebench-verified +extras: ergon-builtins[data] +model: explicit CLI value, e.g. openai:gpt-4o +``` + +## GDPEval + +### Folder + +```text +benchmarks/gdpeval/ + benchmark.py + task_schemas.py + loader.py + worker_factory.py + criteria.py + rubric.py + sandbox.py +``` + +### Benchmark + +`GDPEvalBenchmark` should stay in the `[data]` registry: + +- `type_slug = "gdpeval"` +- `task_payload_model = GDPTaskConfig` +- `onboarding_deps = BenchmarkRequirements(e2b=True, extras=("ergon-builtins[data]",))` +- `build_instances()` loads task IDs and reference files from HuggingFace. +- each `Task.description` should be the document-processing instruction extracted from the dataset. + +The payload should carry: + +- `task_id` +- `workflow_type` +- `reference_files` +- any expected output manifest or rubric category references needed by evaluation + +### Worker + +GDPEval should have an explicit recommended worker pairing instead of depending on a generic ReAct slug that has no benchmark tools. The worker can be implemented in either of two ways: + +- `gdpeval-react`: benchmark-owned factory around shared ReAct, with document/file tools and sandbox workspace instructions. +- `gdpeval-workflow-cli-react`: if GDP tasks are meant to exercise the workflow CLI and produce office artifacts through the sandbox. + +The recommended first target is `gdpeval-react` because it keeps the benchmark in the same authoring pattern as MiniF2F and SWE-Bench. + +### Criteria And Rubric + +`StagedRubric` is an advanced evaluator-like rubric because it supports sequential gates and stage-specific failure actions. It should be registered under one stable slug: + +```text +gdpeval-staged-rubric +``` + +If the CLI keeps the shorter compatibility slug during the rebuild, it should be temporary and removed in the coordinated built-ins rename. + +GDPEval criteria should be generated from explicit stage definitions: + +- format/file existence gates +- reference-file consistency checks +- LLM judge criteria for qualitative document quality +- optional code or spreadsheet checks for generated artifacts + +Each criterion should emit structured evidence for auditability: + +- files checked +- sandbox command IDs +- judge prompt messages +- parsed outputs +- failure reason + +### Required CLI Pairing + +```text +benchmark_slug: gdpeval +worker_slug: gdpeval-react +evaluator_slug: gdpeval-staged-rubric +sandbox_slug: gdpeval +extras: ergon-builtins[data] +model: explicit CLI value, e.g. openai:gpt-4o +``` + +## ResearchRubrics + +### Folder + +```text +benchmarks/researchrubrics/ + benchmark.py + vanilla.py + task_schemas.py + worker_factory.py + researcher_worker.py + workflow_cli_react_worker.py + criteria.py + judge_criterion.py + rubric.py + sandbox_manager.py +``` + +### Benchmark + +`ResearchRubricsBenchmark` and `ResearchRubricsVanillaBenchmark` should remain `[data]` benchmarks: + +- `type_slug = "researchrubrics"` and `type_slug = "researchrubrics-vanilla"` +- `task_payload_model = ResearchRubricsTaskPayload` +- `onboarding_deps = BenchmarkRequirements(extras=("ergon-builtins[data]",), optional_keys=("EXA_API_KEY",))` +- `build_instances()` returns one `Task` per dataset sample. +- `description` should be the research prompt. + +The payload should carry: + +- `sample_id` +- `domain` +- `prompt` +- list of weighted rubric criteria + +### Workers + +ResearchRubrics should keep two registered worker choices because they exercise different research-agent paths: + +```text +researchrubrics-researcher +researchrubrics-workflow-cli-react +``` + +`researchrubrics-researcher` should be the recommended first worker pairing: + +- accepts the research prompt +- uses model-backed research behavior +- writes final report artifacts through `WorkerContext` or public resource capabilities +- returns `WorkerOutput` with report summary and final artifact references + +`researchrubrics-workflow-cli-react` should remain an advanced/experimental worker: + +- uses the workflow CLI path inside the sandbox +- is useful for testing tool orchestration and dashboard traces +- should not be the default unless the CLI explicitly requests it + +### Criteria And Rubric + +`ResearchRubricsRubric` should remain an advanced dynamic evaluator or a `Rubric` that overrides `criteria_for(task)`, because its criteria come from each task payload. + +The task-specific path should: + +1. read `ResearchRubricsTaskPayload.rubrics` +2. build one `ResearchRubricsJudgeCriterion` per rubric criterion +3. evaluate the final report against each weighted criterion +4. aggregate positive and negative weights into normalized `TaskEvaluationResult` + +Judge criteria should use `CriterionEvidence` to preserve: + +- judge prompt +- report excerpt or artifact reference +- rubric criterion text +- axis and weight +- model output + +### Required CLI Pairings + +```text +benchmark_slug: researchrubrics +worker_slug: researchrubrics-researcher +evaluator_slug: researchrubrics-rubric +sandbox_slug: researchrubrics +extras: ergon-builtins[data] +model: explicit CLI value, e.g. openai:gpt-4o +``` + +```text +benchmark_slug: researchrubrics-vanilla +worker_slug: researchrubrics-researcher +evaluator_slug: researchrubrics-rubric +sandbox_slug: researchrubrics-vanilla +extras: ergon-builtins[data] +model: explicit CLI value, e.g. openai:gpt-4o +``` + +## CLI Requirements + +The CLI should not know benchmark internals. It should consume registry metadata and call core service facades. + +### Discovery + +`ergon benchmark list` should display: + +- slug +- description +- available registered workers +- available registered evaluators +- sandbox requirement +- data extra requirement + +`ergon worker list` and `ergon evaluator list` should continue to read `WORKERS` and `EVALUATORS`. + +### Experiment Define + +`ergon experiment define ` should: + +1. require explicit `--worker`, `--evaluator`, `--sandbox`, `--model`, and `--extras` or equivalent request fields +2. validate those explicit slugs against the registries +3. instantiate the benchmark by slug +4. call `build_instances()` +5. select samples by `--limit`, `--sample`, or future selection flags +6. persist an `ExperimentRecord` with benchmark slug, selected instance keys, explicit worker team JSON, evaluator slug, sandbox slug, model target, extras/dependency intent, and cohort metadata + +It should not instantiate workers or criteria at define time. + +### Experiment Run + +`ergon experiment run ` should: + +1. read the persisted experiment +2. create one run assignment per selected task or instance +3. build a single-sample workflow definition through core composition +4. persist the workflow definition with benchmark, worker, and evaluator slugs +5. create `RunRecord` rows linked to experiment/cohort/definition +6. emit workflow start events + +Workers, criteria, and sandbox managers are instantiated by runtime services from slugs after run creation. + +### Benchmark Run + +`ergon benchmark run ` should become a convenience wrapper around define plus run. It should not keep its own separate composition path long term. + +The rebuild should remove drift between: + +- `ergon_cli.commands.benchmark.run_benchmark` +- `ergon_cli.composition.build_experiment` +- `ExperimentDefinitionService` +- `ExperimentLaunchService` + +The preferred end state is: + +```text +benchmark run + -> experiment facade define + -> experiment facade run + -> run facade status/output +``` + +## Migration Order + +### Phase 1: Explicit Registry Contract + +- Keep registries explicit: no benchmark profiles or default pairing layer in this phase. +- Ensure `BENCHMARKS`, `WORKERS`, `EVALUATORS`, `SANDBOX_MANAGERS`, and `SANDBOX_TEMPLATES` are complete and typed. +- Update CLI list commands to display registered components without implying defaults. +- Add tests that every documented CLI pairing references registered benchmark, worker, evaluator, and sandbox slugs. + +### Phase 2: Public API Imports + +- Replace old built-ins imports: + - `BenchmarkTask` -> `Task` + - `BenchmarkDeps` -> `BenchmarkRequirements` + - `EvaluationContext` -> `CriterionContext` + - `CriterionResult` -> `CriterionOutcome` + - `CriterionScoreSpec` -> `ScoreScale` + - `CriterionObservation` -> `CriterionEvidence` + - `CriterionObservationMessage` -> `EvidenceMessage` +- Move SWE-Bench rubric beside the SWE-Bench benchmark. +- Move generic evaluator helpers under `shared/criteria` only if they are truly benchmark-independent. + +### Phase 3: Benchmark-Owned Worker Factories + +- Move `_minif2f_react` into `benchmarks/minif2f/worker_factory.py`. +- Move `_swebench_react` into `benchmarks/swebench_verified/worker_factory.py`. +- Add `gdpeval-react` factory. +- Keep ResearchRubrics workers in the benchmark package or re-export them from benchmark-owned `worker_factory.py`. +- Keep generic `ReActWorker` in `shared/workers/react_worker.py`. + +### Phase 4: CLI Facade Alignment + +- Make `benchmark run` call the same core service facade path as `experiment define` plus `experiment run`. +- Remove direct CLI composition of `Experiment` objects. +- Ensure `create_run` call sites use the current `RunRecord` contract: experiment ID, workflow definition ID, instance key, worker team JSON, evaluator slug, and model target. + +### Phase 5: Runtime And Evaluation Contracts + +- Update Inngest worker execution to construct `Task` from the registered benchmark payload model. +- Update evaluation execution to use `CriterionContext` public capability methods. +- Ensure sandbox setup happens before benchmark-owned worker factories are invoked. +- Ensure criteria never import persistence sessions or concrete runtime protocols through public API modules. + +## Testing Plan + +Core contract tests: + +- every `BENCHMARKS` key has a matching `Benchmark.type_slug` +- every documented required CLI pairing has registered benchmark, worker, evaluator, and sandbox slugs +- every benchmark exposes `task_payload_model` and `BenchmarkRequirements` +- every benchmark's `build_instances(limit=1)` returns at least one `Task` with a valid payload when optional dependencies are available + +Benchmark-specific tests: + +- MiniF2F proof criterion handles verified, syntactically valid incomplete, and invalid proof outputs. +- SWE-Bench criterion handles empty patch, patch extraction failure, git apply failure, unresolved report, and resolved report. +- GDPEval staged rubric handles required gate failure, continue, zero-category, and normalized score bounds. +- ResearchRubrics dynamic criteria build one judge criterion per payload rubric and aggregate negative weights correctly. + +CLI/service tests: + +- `benchmark list` shows registered benchmarks without default worker/evaluator metadata. +- `experiment define` stores slugs and selected sample keys, not live worker/evaluator objects. +- `experiment run` creates one workflow definition and run per selected sample. +- `benchmark run` uses the same facade path as define plus run. +- run records persist worker team JSON, evaluator slug, model target, instance key, experiment ID, and workflow definition ID. + +## Open Decisions + +1. Whether `Evaluator` stays root-public or is imported only from `ergon_core.api.rubric`. +2. Whether `gdpeval-react` should be the recommended GDP worker or GDP should use the workflow CLI worker. +3. Whether `researchrubrics-rubric` is the only final slug, removing `research-rubric`. +4. Whether `benchmark run` should remain as a public CLI command after it becomes a wrapper around experiment services. diff --git a/docs/superpowers/plans/2026-04-28-ergon-cli-refactor-structure.md b/docs/superpowers/plans/2026-04-28-ergon-cli-refactor-structure.md new file mode 100644 index 00000000..566b72f6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-ergon-cli-refactor-structure.md @@ -0,0 +1,772 @@ +# Ergon CLI Refactor Structure + +This document specifies the target CLI structure after the Ergon core public API and `ergon_builtins` package refactors. It is a sibling to: + +- `2026-04-28-public-api-target-structure.md` +- `2026-04-28-ergon-builtins-rebuild-structure.md` + +The CLI should become the operator-facing shell over core service facades. It should not assemble low-level graph objects by hand, import benchmark internals, or maintain a second experiment launch path that can drift from the API and runtime. + +## Goals + +- Make `ergon experiment define` and `ergon experiment run` the canonical local lifecycle commands. +- Make API routes, CLI commands, and eval automation call the same core services with the same DTOs. +- Make `benchmark run`, if kept, a thin wrapper over define plus run. +- Use `ergon_builtins.registry` for discovery and validation, but require explicit worker/evaluator/sandbox/model/extras choices in benchmark requests for now. +- Remove stale direct composition paths from the CLI. +- Keep operational commands such as `benchmark setup`, `workflow`, `run list`, `run cancel`, `doctor`, `onboard`, and `train` clearly separated from experiment definition and launch. +- Ensure CLI output remains machine-readable enough for tests, shell scripts, and eval automation. + +## Current Shape + +```text +ergon_cli/ + ergon_cli/ + main.py + # top-level argparse parser and dispatch + + commands/ + benchmark.py + # list, setup, stale run path + experiment.py + # define, run, show, list + run.py + # list, cancel + worker.py + # list + evaluator.py + # list + workflow.py + # sandbox/workflow helper commands + eval.py + # checkpoint eval watcher + train.py + # local RL training + onboard.py + doctor.py + + composition/ + __init__.py + # stale direct Experiment composition helper + + discovery/ + __init__.py + # list BENCHMARKS/WORKERS/EVALUATORS + + rendering/ + __init__.py +``` + +The current parser registers: + +- `benchmark list` +- `benchmark setup` +- `experiment define` +- `experiment run` +- `experiment show` +- `experiment list` +- `run list` +- `run cancel` +- `worker list` +- `evaluator list` +- `workflow ...` +- `eval watch` +- `eval checkpoint` +- `onboard` +- `doctor` +- `train local` + +There is handler code for `benchmark run`, but `main.py` does not register a `benchmark run` subparser. This is intentional in at least one current unit test, but conflicts with dead handler code, old setup messages, and real-LLM tests that still invoke `ergon benchmark run`. + +## Target Command Model + +```mermaid +flowchart TD + accTitle: CLI Command Ownership + accDescr: The CLI command tree routes experiment lifecycle commands through core service facades, while setup, workflow, training, and diagnostics remain separate operational surfaces. + + cli["ergon CLI"] + discovery["discovery commands
benchmark/worker/evaluator list"] + setup["benchmark setup
sandbox template build"] + experiment["experiment lifecycle
define/run/show/list"] + run["run operations
list/cancel/show later"] + workflow["workflow helper
inside sandbox/task context"] + eval["eval watcher
checkpoint scoring"] + train["train local
RL training"] + doctor["doctor/onboard"] + + cli --> discovery + cli --> setup + cli --> experiment + cli --> run + cli --> workflow + cli --> eval + cli --> train + cli --> doctor + + experiment --> services["core runtime service facades"] + eval --> experiment + discovery --> registry["ergon_builtins.registry"] + setup --> sandbox_templates["SANDBOX_TEMPLATES"] +``` + +### Canonical Lifecycle Commands + +These commands define the supported local experiment lifecycle: + +```text +ergon experiment define [selection] --worker ... --evaluator ... --sandbox ... --model ... --extras ... +ergon experiment run [runtime options] +ergon experiment show +ergon experiment list +``` + +The HTTP API should remain parallel to this command set: + +```text +POST /api/experiments/define +POST /api/experiments/{id}/run +GET /api/experiments/{id} +GET /api/experiments +``` + +The CLI and HTTP API should use the same service layer: + +- `ExperimentDefinitionService.define_benchmark_experiment` +- `ExperimentLaunchService.run_experiment` +- `ExperimentReadService.get_experiment` +- `ExperimentReadService.list_experiments` +- `ExperimentCohortService.resolve_or_create` +- run read/cancel services + +### Wrapper Commands + +`ergon benchmark run` has two acceptable end states: + +1. Preferred: reintroduce it as a convenience wrapper over `experiment define` plus `experiment run`. +2. Strict: delete the handler and update all docs/tests to use `ergon experiment define` plus `ergon experiment run`. + +The preferred end state is to keep it as a wrapper because it is useful for demos and real-LLM canaries: + +```text +ergon benchmark run minif2f --limit 1 + +equivalent to: + ergon experiment define minif2f --limit 1 --worker minif2f-react --model openai:gpt-4o --evaluator minif2f-rubric --sandbox minif2f --extras none + ergon experiment run +``` + +The wrapper must not call `ergon_cli.composition.build_experiment` or create `RunRecord` rows itself. + +### Operational Commands + +These commands should stay outside the experiment lifecycle: + +- `ergon benchmark setup `: build/register E2B sandbox templates. +- `ergon workflow ...`: task-local workflow/resource helper used inside workers and sandboxes. +- `ergon run list`: operator telemetry over recent runs. +- `ergon run cancel `: cancellation and cleanup request. +- `ergon eval watch` and `ergon eval checkpoint`: checkpoint evaluation automation. +- `ergon train local`: local training integration. +- `ergon doctor` and `ergon onboard`: environment setup and diagnostics. + +## Target Package Tree + +```text +ergon_cli/ + ergon_cli/ + main.py + # argparse only; no business logic + + commands/ + benchmark.py + # list, setup, wrapper run only + experiment.py + # define, run, show, list through facade helpers + run.py + # list, cancel through run services + worker.py + evaluator.py + workflow.py + eval.py + train.py + onboard.py + doctor.py + + services/ + experiment_cli_facade.py + # CLI-specific orchestration over core service DTOs + # parse args -> requests -> logging/rendering + benchmark_cli_facade.py + # benchmark list/setup/wrapper helpers + run_cli_facade.py + # list/cancel/show helpers + + discovery/ + __init__.py + # registry reads only + + rendering/ + __init__.py + # tables, key=value output, errors + + parsing/ + __init__.py + # optional shared parser helper functions if main.py grows too large +``` + +`ergon_cli.composition` should be removed once `benchmark run` and smoke-only composition paths are replaced by service facade calls or test harness APIs. + +## Service Boundary + +The CLI may import: + +```python +from ergon_builtins.registry import ( + BENCHMARKS, + WORKERS, + EVALUATORS, + SANDBOX_MANAGERS, + SANDBOX_TEMPLATES, +) + +from ergon_core.core.runtime.services.experiment_definition_service import ExperimentDefinitionService +from ergon_core.core.runtime.services.experiment_launch_service import ExperimentLaunchService +from ergon_core.core.runtime.services.experiment_read_service import ExperimentReadService +from ergon_core.core.runtime.services.experiment_schemas import ExperimentDefineRequest, ExperimentRunRequest +from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service +from ergon_core.core.runtime.services.run_service import cancel_run +``` + +The CLI should not import: + +- `ergon_core.core.composition.Experiment` except inside a temporary migration shim. +- `ergon_core.core.composition.WorkerSpec` except inside core services. +- benchmark package internals such as `ergon_builtins.benchmarks.minif2f.*`. +- concrete criterion classes. +- persistence model classes for command logic, except through a temporary run-list shim. +- Inngest event classes for experiment launch, except through core services. + +## Discovery Commands + +### `ergon benchmark list` + +Use `BENCHMARKS` plus the related worker/evaluator/sandbox registries for validation. Do not show or infer benchmark defaults in this phase. + +Target columns: + +```text +Slug +Name +Description +Requires Data Extra +Known Sandboxes +``` + +Rules: + +- Include all registered benchmark slugs. +- Do not display default workers or evaluators. +- Show dependency hints only when they come from `BenchmarkRequirements` or explicit registry metadata. +- A contract test should fail if CLI code starts deriving hidden worker/evaluator/sandbox defaults. + +### `ergon worker list` + +Use `WORKERS`. + +Target columns: + +```text +Slug +Name +Kind +Description +``` + +`Kind` can initially be inferred: + +- `class` +- `factory` + +Long term, worker metadata can move into an explicit descriptor object if the registry grows. + +### `ergon evaluator list` + +Use `EVALUATORS`. + +Target columns: + +```text +Slug +Name +Kind +Description +``` + +`Kind` can be: + +- `rubric` +- `evaluator` + +If `Evaluator` remains advanced public API, list it as an advanced evaluator, not a beginner rubric. + +## Experiment Define + +### Command + +```text +ergon experiment define + (--limit N | --sample-id SAMPLE_ID ...) + [--name NAME] + [--cohort COHORT_NAME] + --worker WORKER_SLUG + --model MODEL_TARGET + --evaluator EVALUATOR_SLUG + --sandbox SANDBOX_SLUG + --extras EXTRAS_SPEC + [--workflow single] + [--max-questions N] +``` + +The CLI should keep these choices compulsory while the package structure is stabilizing. A benchmark slug alone is not enough information to define an experiment. + +### Data Flow + +```mermaid +sequenceDiagram + accTitle: Experiment Define Flow + accDescr: The CLI validates explicit registry slugs, builds a request DTO, and delegates experiment definition to core services. + + participant User + participant CLI + participant Registry + participant Cohorts + participant DefinitionService + participant DB + + User->>CLI: ergon experiment define minif2f --limit 1 + CLI->>Registry: validate explicit benchmark/worker/evaluator/sandbox slugs + CLI->>Cohorts: resolve_or_create when --cohort is set + CLI->>DefinitionService: define_benchmark_experiment(request) + DefinitionService->>Registry: instantiate benchmark by slug + DefinitionService->>DefinitionService: build_instances and select samples + DefinitionService->>DB: persist ExperimentRecord + DefinitionService-->>CLI: ExperimentDefineResult + CLI-->>User: key=value identifiers +``` + +### Request Mapping + +```python +ExperimentDefineRequest( + benchmark_slug=args.benchmark_slug, + name=args.name, + cohort_id=cohort_id, + limit=args.limit, + sample_ids=args.sample_id or None, + default_model_target=args.model, + default_worker_team={"primary": args.worker}, + default_evaluator_slug=args.evaluator, + metadata={ + "workflow": args.workflow, + "max_questions": args.max_questions, + "sandbox_slug": args.sandbox, + "extras": args.extras, + "cli_command": "experiment define", + }, +) +``` + +### Output Contract + +The command should print stable key/value lines: + +```text +EXPERIMENT_ID= +COHORT_ID= # only when known +BENCHMARK= +SAMPLES= +DEFAULT_WORKER= +DEFAULT_EVALUATOR= +DEFAULT_MODEL= +``` + +Tests and automation should parse these lines rather than human prose. + +## Experiment Run + +### Command + +```text +ergon experiment run + [--timeout SECONDS] + [--no-wait] +``` + +### Required Core Behavior + +`ExperimentLaunchService.run_experiment` should own: + +1. read `ExperimentRecord` +2. create one `RunAssignment` per selected sample +3. construct a single-sample benchmark wrapper +4. instantiate evaluator binding from `EVALUATORS` +5. call `Experiment.from_single_worker(...)` +6. persist workflow definition through `ExperimentPersistenceService` +7. create `RunRecord` with: + - `experiment_id` + - `workflow_definition_id` + - `instance_key` + - `worker_team_json` + - `evaluator_slug` + - `model_target` + - optional assignment/seed metadata +8. emit `WorkflowStartedEvent` + +The CLI should not implement any of those steps directly. + +### Wait Semantics + +The current schema includes `timeout_seconds` and `wait`, but the launch service does not fully use them. The target semantics: + +- `wait=True`: return after all created runs reach terminal status or timeout. +- `wait=False`: return immediately after workflow start events are emitted. +- `timeout_seconds`: maximum wait time when `wait=True`. +- Timeout should not cancel the run by default; it should return a non-zero CLI code only for the waiting command. + +The result DTO should carry enough status for output: + +```text +EXPERIMENT_ID= +RUN_ID= +RUN_STATUS= # when wait=True and known +``` + +If multiple runs are launched, print one `RUN_ID=` and `RUN_STATUS=` pair per run, or a tabular block after the stable key/value lines. + +## Experiment Show/List + +`experiment show` should read `ExperimentReadService.get_experiment`. + +Output should include: + +```text +EXPERIMENT_ID= +COHORT_ID= +NAME= +BENCHMARK= +STATUS= +SAMPLE_COUNT= +RUN_COUNT= +DEFAULT_WORKER= +DEFAULT_EVALUATOR= +DEFAULT_MODEL= +SAMPLE_SELECTION= +``` + +If runs exist, print: + +```text +RUNS +\t\t\t +``` + +`experiment list` should remain a summary table. It should not instantiate benchmarks or workers. + +## Benchmark Setup + +`ergon benchmark setup ` remains separate from experiment lifecycle. + +It should: + +1. read `SANDBOX_TEMPLATES` +2. validate `E2B_API_KEY` +3. load the benchmark template spec +4. build the E2B template +5. write `~/.ergon/sandbox_templates.json` or `ERGON_CONFIG_DIR/sandbox_templates.json` +6. print a follow-up command using the canonical lifecycle + +The success message should not suggest stale `benchmark run` syntax unless `benchmark run` is formally kept. + +Preferred success message: + +```text +Success! Template ID: +Next: + ergon experiment define --limit 1 + ergon experiment run +``` + +If `benchmark run` is kept: + +```text +Or: + ergon benchmark run --limit 1 +``` + +## Benchmark Run Wrapper + +If kept, `benchmark run` should be registered in `main.py` and call a wrapper function that does exactly: + +1. require the same explicit worker/evaluator/sandbox/model/extras arguments as `experiment define` +2. validate those explicit choices against registries +3. call the same define facade as `experiment define` +4. call the same run facade as `experiment run` +5. print the same stable key/value output + +Target command: + +```text +ergon benchmark run + [--limit N | --sample-id SAMPLE_ID ...] + [--name NAME] + [--cohort COHORT_NAME] + --worker WORKER_SLUG + --model MODEL_TARGET + --evaluator EVALUATOR_SLUG + --sandbox SANDBOX_SLUG + --extras EXTRAS_SPEC + [--workflow single] + [--timeout SECONDS] + [--no-wait] +``` + +The handler should not call: + +- `build_experiment` +- `Experiment.persist` +- `create_run` directly +- `inngest_client.send` directly + +## Run Commands + +### `ergon run list` + +The current CLI queries `RunRecord` directly. Target state: + +- add a read method in core, either in `RunReadService` or a small `RunListService` +- support `--limit` +- support `--status` +- optionally support `--experiment-id` and `--cohort-id` later + +Output columns: + +```text +RUN_ID +STATUS +EXPERIMENT_ID +WORKFLOW_DEFINITION_ID +INSTANCE_KEY +MODEL +CREATED_AT +UPDATED_AT +``` + +### `ergon run cancel ` + +Keep routed through `run_service.cancel_run`. + +Target behavior: + +- return `0` if cancellation request is accepted +- return non-zero if run is missing or already terminal and cannot be canceled +- print stable key/value output: + +```text +RUN_ID= +STATUS=cancelled +``` + +## Workflow Command + +`ergon workflow` is an internal worker/sandbox helper surface, not an operator experiment lifecycle surface. + +It may continue to call `WorkflowService` directly because it is already scoped by: + +- `--run-id` +- `--node-id` +- `--execution-id` +- `--sandbox-task-key` +- `--benchmark-type` + +Refactor rules: + +- keep it isolated from benchmark definition and launch code +- do not make it import benchmark package internals +- keep `--benchmark-type` as a slug used by sandbox materialization +- add tests that workflow parser changes do not affect experiment parser behavior + +## Eval Commands + +`ergon eval watch` and `ergon eval checkpoint` should use the canonical experiment lifecycle for local evaluation. + +Current target: + +```text +eval checkpoint + -> evaluate_checkpoint + -> local eval path + -> ergon experiment define + -> ergon experiment run + -> read run/evaluation results +``` + +Required cleanup: + +- make `--eval-limit` required for local eval if `_run_local_eval` requires it, or provide a safe default +- ensure subprocess calls use `experiment define/run`, not `benchmark run` +- ensure output parsing relies on stable `EXPERIMENT_ID=` and `RUN_ID=` lines + +## Train Command + +`ergon train local` belongs to training infrastructure and should remain separate from CLI experiment lifecycle. + +It may accept: + +- `--benchmark` +- `--evaluator` +- `--definition-id` +- model/training parameters + +The refactor should not change training semantics unless import paths break. + +## Doctor And Onboard + +`doctor` and `onboard` should use explicit CLI request fields plus benchmark requirements to report missing dependencies. + +Examples: + +- benchmark requires `[data]` +- benchmark requires E2B +- benchmark recommends `EXA_API_KEY` +- benchmark requires sandbox template setup +- model backend requires environment keys + +They should not instantiate benchmark datasets just to list requirements. + +## Migration Plan + +### Phase 1: Parser And Command Contract + +- Decide final `benchmark run` behavior. +- If keeping it, register the parser and implement it as a wrapper. +- If removing it, delete handler code and update tests/docs/real-LLM canaries. +- Update `benchmark setup` success messaging. +- Add parser tests for all command surfaces. + +### Phase 2: Explicit Registry Validation + +- Update `discovery.list_benchmarks()` to display registered benchmarks without implying default pairings. +- Keep `--worker`, `--model`, `--evaluator`, `--sandbox`, and `--extras` required for `experiment define` and `benchmark run`. +- Add validation errors for missing or unknown explicit choices: + - unknown benchmark slug + - unknown worker slug + - unknown evaluator slug + - unknown sandbox slug + - missing model target + - missing extras/dependency intent + +### Phase 3: CLI Facade Extraction + +- Create `ergon_cli/services/experiment_cli_facade.py`. +- Move argument-to-DTO mapping out of command handlers. +- Keep `commands/experiment.py` thin. +- Add `benchmark_cli_facade.py` for list/setup/wrapper run. +- Add `run_cli_facade.py` for list/cancel once run read service exists. + +### Phase 4: Delete Direct Composition Path + +- Remove `ergon_cli.composition.build_experiment` from production CLI flows. +- Move any smoke-only composition behavior into core test harness or test support. +- Ensure no production CLI command imports `Experiment`, `WorkerSpec`, or Inngest events for launch. + +### Phase 5: Wait/Poll Semantics + +- Implement service-level `wait` and `timeout_seconds`, or remove those fields from CLI/schema. +- Prefer implementing them because e2e and demos need blocking behavior. +- Add tests for: + - `--no-wait` returns after dispatch + - timeout returns non-zero without canceling runs + - completed runs return status lines + +### Phase 6: Run Read Service + +- Add a service method for listing recent runs. +- Route `run list` through it. +- Keep `run cancel` through `cancel_run`. +- Add tests for status filtering. + +## Test Plan + +### Unit Tests + +Parser tests: + +- `benchmark list` parses +- `benchmark setup ` parses +- `benchmark run ` parses if kept, fails if removed +- `experiment define` parses only with explicit worker/model/evaluator/sandbox/extras +- `experiment run --no-wait` parses +- `run list --status failed` parses +- `eval checkpoint --eval-limit 1` parses + +Facade tests: + +- define facade builds `ExperimentDefineRequest` with explicit CLI choices +- define facade rejects missing explicit worker/evaluator/sandbox/model/extras +- define facade resolves cohort only when `--cohort` is provided +- run facade builds `ExperimentRunRequest` +- benchmark wrapper calls define then run facades +- benchmark wrapper does not import or call direct composition helpers + +Discovery tests: + +- benchmark list does not imply default worker/evaluator pairings +- worker list includes factory entries +- evaluator list includes rubric/evaluator entries +- discovery does not expose hidden benchmark defaults + +### Integration Tests + +Service/CLI integration tests should cover: + +- `experiment define` persists `ExperimentRecord` with slugs and sample selection +- `experiment run` creates `RunRecord` rows with required foreign keys and assignment JSON +- `benchmark run` wrapper produces the same database shape as define plus run +- `run list` reads persisted runs through service +- `run cancel` emits cancellation and cleanup events + +### E2E Tests + +E2E should keep using: + +```text +ergon experiment define +ergon experiment run +``` + +unless `benchmark run` is explicitly retained as a wrapper, in which case one small canary can prove the wrapper path. + +The full e2e matrix is specified in `2026-04-28-ergon-e2e-refactor-test-plan.md`. + +## Known Drifts To Resolve + +1. `benchmark run` exists in `commands/benchmark.py` but is not registered in `main.py`. +2. `commands/benchmark.py::_create_and_dispatch` calls `create_run` with an old signature. +3. `experiment run --timeout` and `--no-wait` are represented in DTOs but not fully honored by the launch service. +4. `ergon_cli.composition` imports stale public API modules for `Experiment` and `WorkerSpec`. +5. `run list` queries persistence directly instead of using a core read service. +6. `eval checkpoint` can reach local eval without an `eval_limit` even though the local eval helper requires one. +7. `benchmark setup` still prints stale `benchmark run` guidance. + +## Final CLI Contract + +The refactor is complete when: + +- all experiment lifecycle commands go through core service facades +- all discovery commands read registries +- no production CLI command constructs `Experiment` directly +- no production CLI command creates `RunRecord` directly for launch +- `benchmark run` is either a tested wrapper or fully removed +- API, CLI, e2e, and eval automation agree on the same define/run semantics +- stable key/value CLI output is covered by tests diff --git a/docs/superpowers/plans/2026-04-28-ergon-e2e-refactor-test-plan.md b/docs/superpowers/plans/2026-04-28-ergon-e2e-refactor-test-plan.md new file mode 100644 index 00000000..6052b909 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-ergon-e2e-refactor-test-plan.md @@ -0,0 +1,831 @@ +# Ergon E2E Refactor Test Plan + +This document specifies the test strategy that should accompany the Ergon core, built-ins, and CLI refactor. It is a sibling to: + +- `2026-04-28-public-api-target-structure.md` +- `2026-04-28-ergon-builtins-rebuild-structure.md` +- `2026-04-28-ergon-cli-refactor-structure.md` + +The purpose is to keep the refactor self-consistent: public API objects, built-in registry slugs, CLI commands, runtime rehydration, smoke fixtures, e2e harnesses, and dashboard assertions should all prove the same contract. + +## Goals + +- Preserve the existing four-tier testing model: unit, integration, e2e smoke, real-LLM. +- Keep production built-ins separate from test-only smoke fixtures. +- Use e2e smoke to prove cross-process behavior, not pure benchmark logic. +- Ensure CLI define/run behavior is covered by unit and integration tests before e2e uses it. +- Ensure every production benchmark family has contract tests for registry shape and explicit CLI pairing documentation. +- Ensure runtime execution can rehydrate workers, rubrics, criteria, task payloads, and sandbox managers from persisted slugs. +- Keep dashboard and harness checks aligned with run/cohort semantics. + +## Testing Tier Model + +The source of truth should remain path-based: + +```text +tests/unit/ + pure logic, models, validators, registry shape, parser behavior + +tests/integration/ + real Postgres and real Inngest dev server; service, persistence, API boundaries + +tests/e2e/ + full stack, test harness, real E2B, dashboard, Playwright + +tests/real_llm/ + opt-in or nightly; real model calls and budget-gated canaries +``` + +Markers are developer ergonomics, not the canonical tier definition. If `pyproject.toml` marker descriptions conflict with `docs/architecture/07_testing.md`, update the marker descriptions to match the path-based model. + +## High-Level Coverage Map + +```mermaid +flowchart TD + accTitle: Refactor Coverage Map + accDescr: Each test tier proves a different part of the Ergon refactor, from public API contracts through built-in registry shape and CLI service flow to full-stack dashboard behavior. + + public_api["Public API contracts"] + builtins["Built-ins registries
benchmarks/workers/evaluators"] + cli["CLI facades
define/run/list"] + services["Core runtime services
experiments/runs/cohorts"] + runtime["Inngest runtime
worker/evaluator rehydration"] + smoke["E2E smoke fixtures
happy/sad cohorts"] + dashboard["Dashboard and harness"] + + unit["Unit tests"] + integration["Integration tests"] + e2e["E2E smoke tests"] + real_llm["Real-LLM tests"] + + unit --> public_api + unit --> builtins + unit --> cli + integration --> services + integration --> runtime + e2e --> smoke + e2e --> dashboard + real_llm --> builtins + real_llm --> runtime +``` + +## Fixture Residency Rules + +## Stable E2E Boundary After Core Layout Refactor + +Core behavior is stable, but private repository and persistence modules may move. +E2E code should use only: + +- HTTP endpoints under `/api/test/*` +- `ergon_core.test_support` +- public core API objects from `ergon_core.api` +- application read-model facades, not private repository methods + +The existing smoke behavior assertions remain valid: + +- happy runs complete the 12-node graph +- sad runs fail `l_2` and block `l_3` +- happy runs produce 20 task resources and 26 context events +- happy root produces two score-1.0 evaluations +- sad runs produce one partial artifact and seven completion messages + +### Production Built-ins + +Production benchmark code belongs under: + +```text +ergon_builtins/ergon_builtins/ +``` + +Production built-ins include: + +- benchmark loaders +- production task payload schemas +- production worker factories +- production criteria and rubrics +- production sandbox managers +- production registry entries +- shared production worker/model/tool helpers + +Production built-ins must not import: + +- `ergon_core.test_support` +- `tests` +- smoke fixture workers +- smoke fixture criteria +- smoke benchmark loaders + +### Core Test Support + +Canonical smoke fixtures belong under: + +```text +ergon_core/ergon_core/test_support/smoke_fixtures/ +``` + +This package owns: + +- smoke benchmark replacements for `researchrubrics`, `minif2f`, and `swebench-verified` +- smoke workers +- smoke leaf workers +- recursive smoke workers +- sad-path workers +- smoke criteria and smoke rubrics +- `SmokeSandboxManager` +- registry mutation hook `register_smoke_fixtures()` + +Smoke fixtures register into `ergon_builtins.registry` only when explicitly enabled by: + +- `ERGON_STARTUP_PLUGINS=ergon_core.test_support.smoke_fixtures:register_smoke_fixtures` +- `ENABLE_TEST_HARNESS=1` +- `ENABLE_SMOKE_FIXTURES=1` for any remaining host-side transitional paths + +### Tests + +Test drivers and assertions belong under: + +```text +tests/ +``` + +They own: + +- unit parser tests +- registry and explicit pairing contract tests +- integration service tests +- e2e cohort submission +- e2e harness polling +- dashboard Playwright orchestration +- real-LLM canaries + +Tests can import `ergon_core.test_support` in unit/integration contexts. Black-box e2e client code should not register fixtures in the host process; fixture registration should happen inside the API process through startup plugins. + +## Current Smoke Fixture Shape + +```text +ergon_core/ergon_core/test_support/ + __init__.py + # register_smoke_fixtures public hook + + smoke_fixtures/ + __init__.py + # mutates WORKERS, EVALUATORS, and optionally BENCHMARKS/SANDBOX_MANAGERS + + benchmarks.py + # ResearchRubricsSmokeBenchmark + # MiniF2FSmokeBenchmark + # SweBenchSmokeBenchmark + + sandbox.py + # SmokeSandboxManager + + criteria/ + minif2f_smoke.py + researchrubrics_smoke.py + swebench_smoke.py + smoke_rubrics.py + timing.py + + smoke_base/ + worker_base.py + leaf_base.py + recursive.py + sadpath.py + criterion_base.py + subworker.py + constants.py + + workers/ + minif2f_smoke.py + researchrubrics_smoke.py + researchrubrics_smoke_sadpath.py + swebench_smoke.py +``` + +The smoke benchmarks deliberately reuse production benchmark slugs: + +```text +researchrubrics +minif2f +swebench-verified +``` + +They replace production benchmark loaders only when `ENABLE_TEST_HARNESS=1`, so e2e does not need HuggingFace, production data, or LLM access to materialize root tasks. + +## Canonical Smoke Program + +Every PR should continue to run three e2e legs: + +```text +researchrubrics +minif2f +swebench-verified +``` + +Each leg submits a cohort with: + +- one happy-path run +- one sad-path run + +The topology should stay identical across benchmark slugs: + +```text +Diamond: + d_root + / \ + d_left d_right + \ / + d_join + +Line: + l_1 -> l_2 -> l_3 + +Singletons: + s_a + s_b +``` + +Happy-path `l_2` routes to a recursive worker with nested children: + +```text +l_2 +└─ l_2_a -> l_2_b +``` + +Sad-path `l_2` routes to a failing leaf. `l_3` must remain blocked or cancelled according to the static-sibling failure semantics decision. + +## E2E Submission Flow + +```mermaid +sequenceDiagram + accTitle: Smoke E2E Flow + accDescr: E2E tests submit benchmark cohorts through the HTTP test harness, then assert run state through API and dashboard surfaces. + + participant Pytest + participant Harness as API Test Harness + participant Registry + participant Services + participant Inngest + participant Dashboard + + Pytest->>Harness: POST /api/test/write/cohort + Harness->>Registry: resolve smoke benchmark/worker/evaluator slugs + Harness->>Services: define/persist/dispatch runs + Services->>Inngest: WorkflowStartedEvent + Inngest->>Registry: rehydrate smoke workers/evaluators + Pytest->>Harness: poll /api/test/read/cohort/{key}/runs + Pytest->>Harness: read /api/test/read/run/{id}/state + Pytest->>Dashboard: Playwright assertions by cohort/run +``` + +The black-box e2e tests should not: + +- import production internals +- call `build_experiment` +- call `create_run` +- send Inngest events directly +- register smoke fixtures in the host pytest process + +The API process owns fixture registration through `ERGON_STARTUP_PLUGINS`. + +## CLI Coverage Flow + +CLI tests should be split by tier: + +```text +unit: + parser and facade DTO mapping + +integration: + experiment define/run persistence and dispatch semantics + +e2e: + one small black-box CLI canary only if needed +``` + +The canonical e2e smoke path should use the HTTP test harness, not the CLI, because it is primarily proving cross-process runtime, sandbox, dashboard, and cohort behavior. CLI define/run gets its own unit and integration coverage. + +If `benchmark run` is kept as a wrapper, add exactly one CLI e2e canary proving wrapper wiring. Do not duplicate the full smoke matrix through both HTTP harness and CLI. + +## Unit Test Plan + +### Public API Contract Tests + +Add or update tests under: + +```text +tests/unit/architecture/ +tests/unit/api/ +``` + +Required assertions: + +- `ergon_core.api` exports beginner public symbols: + - `Benchmark` + - `Task` + - `EmptyTaskPayload` + - `BenchmarkRequirements` + - `Worker` + - `WorkerContext` + - `WorkerOutput` + - `Criterion` + - `CriterionContext` + - `CriterionOutcome` + - `ScoreScale` + - `CriterionEvidence` + - `EvidenceMessage` + - `Rubric` + - `TaskEvaluationResult` + - `CriterionCheckError` +- moved core composition types are not root-public authoring concepts: + - `Experiment` + - `WorkerSpec` + - `DefinitionHandle` +- public API modules do not import DB/session modules. +- public worker code does not import context event repositories for default output extraction. + +### Built-ins Registry And Pairing Tests + +Add or update tests under: + +```text +tests/unit/registry/ +tests/unit/builtins/ +tests/unit/benchmarks/ +tests/unit/state/ +``` + +Required assertions: + +- every `BENCHMARKS` key equals the benchmark class `type_slug` +- every benchmark exposes `task_payload_model` +- every benchmark exposes `BenchmarkRequirements` +- every documented CLI pairing references registered benchmark, worker, evaluator, and sandbox slugs +- no production code derives hidden worker/evaluator/sandbox defaults from a benchmark slug +- importing `registry_core.py` does not require `[data]` dependencies +- importing `registry_data.py` is allowed to require optional data extras +- production registries do not include smoke worker slugs +- smoke fixture registration is idempotent +- smoke fixture registration only overrides benchmark loaders when `ENABLE_TEST_HARNESS=1` + +### CLI Unit Tests + +Add or update tests under: + +```text +tests/unit/cli/ +``` + +Required assertions: + +- parser registers all canonical commands +- parser outcome for `benchmark run` matches the decision in the CLI spec +- `experiment define` requires explicit `--worker`, `--model`, `--evaluator`, `--sandbox`, and `--extras` +- missing explicit worker/model/evaluator/sandbox/extras values fail before service calls +- define facade builds `ExperimentDefineRequest` +- run facade builds `ExperimentRunRequest` +- benchmark wrapper calls define plus run facades if kept +- `benchmark setup` success guidance uses canonical commands +- discovery output does not imply hidden benchmark defaults +- `run list` delegates to run read service after that service exists +- `eval checkpoint` handles missing or default `--eval-limit` consistently + +### Smoke Fixture Unit Tests + +Keep and extend tests under: + +```text +tests/unit/smoke_base/ +``` + +Required assertions: + +- topology constants remain the single source of truth +- `SmokeWorkerBase.execute` remains final +- every environment has: + - happy parent worker + - leaf worker + - recursive worker + - sad-path parent + - failing leaf + - smoke rubric +- all smoke workers accept the current public `Worker` constructor contract +- smoke criteria use the public `CriterionContext` capability surface +- smoke benchmark payload schemas match production payload shape enough for runtime serialization +- e2e driver pairs exist for every smoke environment + +### Architecture Boundary Tests + +Keep and extend: + +```text +tests/unit/architecture/test_no_test_logic_in_core.py +tests/unit/architecture/test_smoke_fixture_package_boundary.py +``` + +Target assertions: + +- production core does not import `ergon_core.test_support` except explicit test harness/plugin loading points +- `ergon_builtins` does not import `ergon_core.test_support` +- `ergon_builtins` does not import `tests` +- `ergon_cli` production commands do not import smoke fixture modules +- API startup plugin loader may import configured plugins dynamically +- `/api/test/*` is mounted only when `ENABLE_TEST_HARNESS=1` + +## Integration Test Plan + +Integration tests use real Postgres and real Inngest dev server. They should not require real LLM calls. + +### Experiment Services + +Add or update tests under: + +```text +tests/integration/ +tests/unit/runtime/ +``` + +Required scenarios: + +1. Define experiment from a smoke benchmark slug. +2. Persist selected sample keys and explicit worker/evaluator/sandbox/model/extras choices. +3. Run experiment and create one `RunRecord` per selected sample. +4. Persist workflow definition with benchmark, worker, and evaluator slugs. +5. Emit `WorkflowStartedEvent` for each run. +6. Support `wait=False` path. +7. Support timeout path without deleting or cancelling the run. + +### Runtime Rehydration + +Required scenarios: + +- worker execution rehydrates worker factory from `WORKERS` +- worker execution validates task payload through registered benchmark payload model +- evaluator execution rehydrates evaluator from `EVALUATORS` +- criteria run against `CriterionContext`, not direct concrete runtime imports in public modules +- sandbox manager is resolved from `SANDBOX_MANAGERS` +- sandbox setup completes before benchmark-owned worker factories are invoked +- failed worker path persists partial artifacts and marks downstream dependencies correctly + +### Sandbox Integration + +Keep benchmark-specific sandbox manager tests: + +```text +tests/integration/minif2f/test_sandbox_manager.py +tests/integration/researchrubrics/test_sandbox_manager.py +tests/integration/swebench_verified/test_sandbox_manager.py +tests/integration/sandbox/test_required_env_keys.py +``` + +Refactor expectations: + +- these tests should import benchmark sandbox managers from final package locations +- they should not depend on CLI composition helpers +- they should be skipped or marked clearly when E2B credentials are absent, according to current integration policy + +### Evaluator Integration + +Keep and align: + +```text +tests/integration/minif2f/test_verification_integration.py +tests/integration/swebench_verified/test_criterion.py +tests/integration/swebench_verified/test_rubric.py +``` + +Required updates: + +- import renamed public result/context classes +- assert `CriterionOutcome` evidence fields where appropriate +- avoid old `EvaluationContext` naming +- ensure SWE-Bench criterion patch extraction uses `CriterionContext` capabilities + +## E2E Smoke Test Plan + +### Python E2E Layout + +Target layout: + +```text +tests/e2e/ + conftest.py + # infra preflight, shared DB session, optional CLI helper + + _submit.py + # black-box cohort submission through /api/test/write/cohort + + _asserts.py + # run graph, resources, evaluation, communication, sandbox assertions + + _read_contracts.py + # DTO helpers for /api/test/read endpoints + + test_researchrubrics_smoke.py + test_minif2f_smoke.py + test_swebench_smoke.py +``` + +Each `test__smoke.py` should: + +1. build a cohort key +2. submit two slots: + - happy smoke worker plus smoke rubric + - sad-path smoke worker plus smoke rubric +3. wait for terminal statuses +4. assert happy run graph/resources/evaluations/messages +5. assert sad run partial artifacts and blocked/cancelled downstream node +6. run the dashboard Playwright smoke spec for that environment + +### Required Per-Run Assertions + +Happy run assertions: + +- root node completed +- expected direct child nodes exist +- nested `l_2_a` and `l_2_b` exist +- dependency edges match canonical topology +- all expected leaf/dynamic nodes completed +- `GenerationTurn` count matches expected topology +- communication thread messages exist in order +- run resources include outputs and probe artifacts +- blob store round-trip works +- root evaluations exist +- evaluation timestamps are after root execution completion +- sandbox health probe succeeded + +Sad run assertions: + +- root node reaches failed or terminal failed-equivalent state +- `l_2` failed +- `l_3` blocked or cancelled until the failure semantics RFC pins final status +- partial artifact from failing leaf exists +- pre-failure sandbox WAL entry exists when WAL persistence exists +- no successful final evaluation score is recorded +- unaffected branches completed as expected + +### Dashboard Assertions + +Dashboard e2e specs under: + +```text +ergon-dashboard/tests/e2e/ +``` + +should assert: + +- cohort page renders both happy and sad runs +- run status is visible +- graph canvas renders +- each expected task node appears by `data-testid` +- environment label appears +- failed/blocked node states are visible on sad path +- evaluation panel shows root evaluation where expected +- resources/artifacts are visible where expected + +Backend harness DTOs should remain the source of truth for data-rich assertions; Playwright should assert that the UI represents the same state. + +## Real-LLM Test Plan + +Real-LLM tests are opt-in and should not block ordinary local development. + +Target directory: + +```text +tests/real_llm/ + benchmarks/ + test_researchrubrics.py + test_minif2f.py # optional future canary + test_swebench.py # optional future canary + test_smoke_stub.py + fixtures/ + stack.py + harness_client.py + playwright_client.py + openrouter_budget.py +``` + +Required canaries: + +- one no-LLM stub model canary proving CLI wrapper behavior if `benchmark run` is kept +- one ResearchRubrics real model run proving report generation and LLM judge path + +Optional canaries: + +- MiniF2F real model proof attempt +- SWE-Bench real model patch attempt + +Real-LLM tests should use strict budgets and explicit environment gates: + +- `ERGON_REAL_LLM=1` +- OpenRouter/OpenAI/Anthropic keys as required +- stack readiness fixtures + +## Test Harness Contract + +The `/api/test/*` harness should remain test-only. + +Mounting rules: + +- enabled only when `ENABLE_TEST_HARNESS=1` +- write endpoints require `X-Test-Secret` or configured secret behavior +- read endpoints are safe for Playwright and pytest polling in test environments + +Required endpoints: + +```text +POST /api/test/write/cohort +GET /api/test/read/cohort/{cohort_key}/runs +GET /api/test/read/run/{run_id}/state +``` + +The write endpoint should use the same core services as production experiment launch. It may use smoke fixture registry entries, but it should not keep a separate run creation path that bypasses service invariants. + +## Coverage Matrix + +| Area | Unit | Integration | E2E Smoke | Real-LLM | +|---|---|---|---|---| +| Public API exports | required | no | no | no | +| Public API import boundaries | required | no | no | no | +| Built-ins registry and explicit pairing shape | required | optional | indirect | optional | +| Benchmark `build_instances` contract | required with stubs | data-dependent paths | smoke replacements | real datasets optional | +| CLI parser/facade mapping | required | optional | one canary only | optional | +| Experiment define/run services | fast mocked unit plus contract tests | required | indirect through harness | indirect | +| Run creation schema | required | required | indirect | indirect | +| Inngest worker rehydration | required | required | required | required for canaries | +| Evaluator/criterion rehydration | required | required | required | required for judge canaries | +| Sandbox manager setup | unit stubs | required per benchmark | required smoke path | optional | +| Dashboard event contracts | required | optional | required | optional | +| Cohort happy/sad behavior | unit topology | service-level partial | required | optional | +| LLM generation quality | no | no | no | required | + +## Migration Plan + +### Phase 1: Freeze Test Boundaries + +- Update this plan and `docs/architecture/07_testing.md` if necessary. +- Align `pyproject.toml` marker descriptions with the path-based tier model. +- Add boundary tests proving production built-ins do not import smoke/test modules. +- Add tests proving smoke fixtures register only through explicit hooks. + +### Phase 2: Public API Rename Tests + +- Update unit tests to use final public names: + - `Task` + - `BenchmarkRequirements` + - `CriterionContext` + - `CriterionOutcome` + - `ScoreScale` + - `CriterionEvidence` + - `EvidenceMessage` +- Keep no compatibility alias tests unless the product decision changes. + +### Phase 3: Built-ins Registry And Pairing Tests + +- Add explicit pairing contract tests for: + - `minif2f` + - `swebench-verified` + - `gdpeval` + - `researchrubrics` + - `researchrubrics-vanilla` +- Add optional dependency import tests for `registry_core.py` versus `registry_data.py`. + +### Phase 4: CLI Contract Tests + +- Update parser tests around the final `benchmark run` decision. +- Add facade tests for define/run DTO mapping. +- Add integration tests for `experiment define` and `experiment run`. +- Update real-LLM tests to use canonical CLI commands or the wrapper if retained. + +### Phase 5: Runtime Rehydration Tests + +- Update Inngest worker execution tests for final `Task` payload paths. +- Update evaluator execution tests for final `CriterionContext` and `CriterionOutcome`. +- Add regression tests for sandbox setup before worker factory invocation. +- Add tests for persisted slugs matching registry keys. + +### Phase 6: E2E Harness Alignment + +- Ensure `/api/test/write/cohort` calls the same core launch service path as CLI/API. +- Ensure e2e host process does not register fixtures. +- Ensure API process registers fixtures by startup plugin. +- Ensure smoke benchmark replacements override production benchmark loaders only when `ENABLE_TEST_HARNESS=1`. +- Keep Playwright specs aligned with expected smoke topology constants. + +### Phase 7: Dashboard And Artifact Assertions + +- Turn soft-skipped sandbox WAL assertions into hard assertions once WAL persistence exists. +- Keep screenshots on failure. +- Verify dashboard `data-testid` attributes remain stable: + - `run-status` + - `task-node-{slug}` + - `graph-canvas` + - `cohort-run-row` + - `cohort-env-label` + +## Required Test Files To Update Or Add + +### Unit + +```text +tests/unit/architecture/test_public_api_shape.py +tests/unit/architecture/test_no_test_logic_in_core.py +tests/unit/architecture/test_smoke_fixture_package_boundary.py +tests/unit/registry/test_builtin_pairings.py +tests/unit/registry/test_react_factories.py +tests/unit/cli/test_experiment_cli.py +tests/unit/cli/test_benchmark_setup.py +tests/unit/cli/test_eval_cli_required_fields.py +tests/unit/smoke_base/test_smoke_fixture_registration.py +tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py +``` + +### Integration + +```text +tests/integration/smokes/test_smoke_harness.py +tests/integration/minif2f/test_verification_integration.py +tests/integration/minif2f/test_sandbox_manager.py +tests/integration/researchrubrics/test_sandbox_manager.py +tests/integration/swebench_verified/test_criterion.py +tests/integration/swebench_verified/test_rubric.py +tests/integration/swebench_verified/test_sandbox_manager.py +tests/integration/sandbox/test_required_env_keys.py +``` + +Add, if missing: + +```text +tests/integration/cli/test_experiment_define_run.py +tests/integration/runtime/test_registry_rehydration.py +tests/integration/runtime/test_experiment_launch_service_wait.py +``` + +### E2E + +```text +tests/e2e/conftest.py +tests/e2e/_submit.py +tests/e2e/_asserts.py +tests/e2e/_read_contracts.py +tests/e2e/test_researchrubrics_smoke.py +tests/e2e/test_minif2f_smoke.py +tests/e2e/test_swebench_smoke.py +``` + +### Dashboard + +```text +ergon-dashboard/tests/e2e/_shared/smoke.ts +ergon-dashboard/tests/e2e/researchrubrics.smoke.spec.ts +ergon-dashboard/tests/e2e/minif2f.smoke.spec.ts +ergon-dashboard/tests/e2e/swebench-verified.smoke.spec.ts +ergon-dashboard/tests/helpers/backendHarnessClient.ts +``` + +### Real-LLM + +```text +tests/real_llm/benchmarks/test_researchrubrics.py +tests/real_llm/benchmarks/test_smoke_stub.py +tests/real_llm/fixtures/stack.py +tests/real_llm/fixtures/harness_client.py +tests/real_llm/fixtures/openrouter_budget.py +``` + +## Acceptance Criteria + +The refactor is test-complete when: + +- unit tests prove public API exports and import boundaries +- unit tests prove built-ins registry and explicit pairing consistency +- unit tests prove CLI parser/facade behavior +- integration tests prove experiment define/run services persist the expected records +- integration tests prove runtime worker/evaluator rehydration from slugs +- e2e tests pass for `researchrubrics`, `minif2f`, and `swebench-verified` +- e2e host process remains a black-box client +- smoke fixtures stay out of production built-ins +- real-LLM tests are updated to the final CLI contract +- dashboard Playwright specs still render and assert cohort/run state + +## 2026-04-29 Finish Plan Update + +The current execution plan for completing the built-ins, CLI, and e2e refactor is: + +```text +docs/superpowers/plans/2026-04-29-finish-builtins-cli-e2e-refactor.md +``` + +That plan supersedes this document's older migration checklist where the two disagree. In particular: + +- `benchmark run` is retained as an explicit `experiment define` plus `experiment run` wrapper. +- E2E smoke submissions must pass explicit `worker`, `evaluator`, `sandbox`, `model`, and `extras` choices through the test harness. +- E2E host-side tests may import `ergon_core.test_support`, public API modules, HTTP `/api/test/*`, and stable application read models, but not private core repository or persistence internals. +- The existing smoke runtime assertions remain hard assertions: happy runs still expect 12 tasks, 10 leaves, 20 resources, 26 context events, 2 root evaluations, and 11 completion messages; sad runs still expect `l_2` failed, `l_3` blocked, one partial artifact, and 7 completion messages. +- Any persistence-level data still needed for e2e assertions should be exposed through `ergon_core.test_support` helpers rather than imported directly by `tests/e2e`. + +## Open Decisions + +1. Whether e2e should include one CLI subprocess canary in addition to HTTP harness submission. +2. Whether sandbox command WAL persistence lands during this refactor or remains a follow-up. +3. Whether `tests/integration/swebench_verified/test_smoke_e2e.py` should be renamed because it is not a full e2e test. diff --git a/docs/superpowers/plans/2026-04-28-runtime-services-layout-audit.md b/docs/superpowers/plans/2026-04-28-runtime-services-layout-audit.md index c4a2430e..acefd6cd 100644 --- a/docs/superpowers/plans/2026-04-28-runtime-services-layout-audit.md +++ b/docs/superpowers/plans/2026-04-28-runtime-services-layout-audit.md @@ -2,10 +2,19 @@ Date: 2026-04-28 -Scope: `ergon_core/ergon_core/core/runtime/services` in the `core-schema-dedup` worktree. +Scope: `ergon_core/ergon_core/core/runtime/services` in the current core/public API refactor branch. This note is an investigation artifact for a later fix/refactor plan. It does not propose a final migration sequence yet. The goal is to identify where `runtime/services` has become a dumping ground, where service shapes are inconsistent, and where logic appears duplicated or split across weak domain boundaries. +Post-refactor update: this audit has been refreshed after the public API nesting refactor and the first core service moves: + +- `Experiment` and `WorkerSpec` now live under `core/composition`. +- The beginner-facing public API is now nested under `api/benchmark`, `api/worker`, `api/criterion`, and `api/rubric`. +- `experiment_validation_service.py` now owns experiment object-graph validation. +- `workflow_propagation_service.py` now owns the former `runtime/execution/propagation.py` graph propagation helpers. + +Most of the original duplication findings still stand. The new public API shape mainly changes the target boundaries: authoring concepts should stay in `ergon_core.api`, composition/definition concepts should sit near `core/composition` and definition services, and graph/task/workflow lifecycle behavior should stop accumulating in a single flat `runtime/services` package. + ## Executive Summary `runtime/services` is doing too many jobs in one flat namespace: @@ -27,6 +36,7 @@ The resulting issue is not just file count. The same concepts are implemented wi - `graph_repository.py` - `graph_lookup.py` - `graph_dto.py` +- `workflow_propagation_service.py` - `task_management_service.py` - `task_inspection_service.py` - `task_management_dto.py` @@ -35,7 +45,26 @@ The resulting issue is not just file count. The same concepts are implemented wi - `subtask_cancellation_dto.py` - `subtask_blocking_service.py` -This is the densest cluster. It covers graph mutation, graph traversal, task/subtask management, inspection, cancellation, blocking, and graph DTOs. +This is the densest cluster. It covers graph mutation, graph traversal, task/subtask management, inspection, cancellation, blocking, propagation, and graph DTOs. Moving propagation into services made the domain boundary clearer: the old `runtime/execution` package was not really a separate layer; propagation belongs with graph lifecycle policy. + +### Experiment Definition And Composition + +- `experiment_validation_service.py` +- `experiment_persistence_service.py` +- `experiment_definition_service.py` +- `experiment_launch_service.py` +- `experiment_schemas.py` +- `experiment_read_service.py` + +This group is now more visible because `Experiment` moved out of the public API and into `core/composition`. These files are not all the same kind of service: + +- `experiment_validation_service.py` validates the in-memory composition object graph. +- `experiment_persistence_service.py` materializes immutable definition rows from composition objects. +- `experiment_definition_service.py` defines experiments from registered benchmark/worker/evaluator slugs. +- `experiment_launch_service.py` bridges persisted definitions into runtime orchestration. +- `experiment_read_service.py` and `experiment_schemas.py` are application/API read models. + +The current flat package hides that sequence. A later refactor should make the pipeline explicit: composition -> definition persistence -> launch -> read model. ### Workflow And Run Lifecycle @@ -45,17 +74,19 @@ This is the densest cluster. It covers graph mutation, graph traversal, task/sub - `workflow_service.py` - `workflow_dto.py` - `orchestration_dto.py` +- `run_snapshot_read_model.py` -This group mixes run lifecycle orchestration with workflow navigation/resource materialization. `workflow_service.py` is read-heavy and tool/API-facing, while `workflow_initialization_service.py` and `workflow_finalization_service.py` are engine lifecycle services. +This group mixes run lifecycle orchestration with workflow navigation/resource materialization. `workflow_service.py` is read-heavy and tool/API-facing, while `workflow_initialization_service.py` and `workflow_finalization_service.py` are engine lifecycle services. `run_snapshot_read_model.py` is already a move in the right direction because it names read-model shaping separately from orchestration. ### Task Execution And Propagation - `task_execution_service.py` - `task_propagation_service.py` +- `workflow_propagation_service.py` - `task_cleanup_service.py` - `task_cleanup_dto.py` -This group owns execution row creation/finalization, graph status updates for task execution, propagation after completion/failure, and cleanup of cancelled task executions. +This group owns execution row creation/finalization, graph status updates for task execution, propagation after completion/failure, and cleanup of cancelled task executions. `workflow_propagation_service.py` is deliberately listed in both graph and task groups because it is the clearest split point: some functions are graph lifecycle primitives, while `TaskPropagationService` is an orchestration wrapper that turns those transitions into schedulable work. ### Evaluation @@ -109,6 +140,24 @@ The current structure is flat and inconsistent: This makes it difficult to infer whether a file is a domain service, transport contract, read model, or persistence adapter. +### Public API Boundary Is Cleaner, But Core Still Needs Adapters + +The public API refactor has reduced the authoring surface to nested packages: + +- `api/benchmark`: `Benchmark`, `Task`, `EmptyTaskPayload`, `BenchmarkRequirements` +- `api/worker`: `Worker`, `WorkerContext`, `WorkerOutput` +- `api/criterion`: `Criterion`, `CriterionContext`, `CriterionOutcome`, `ScoreScale`, evidence types +- `api/rubric`: `Rubric`, `TaskEvaluationResult`, and advanced `Evaluator` + +That is a useful constraint for the services refactor. Runtime services should consume public authoring objects at the boundary where user-authored concepts enter core, but they should not treat `ergon_core.api` as the place for operational concepts like runs, cohorts, graph nodes, or persisted definition handles. + +Current service imports are mostly consistent with that direction: + +- `experiment_validation_service.py`, `experiment_definition_service.py`, `experiment_launch_service.py`, and `rubric_evaluation_service.py` legitimately consume authoring concepts such as `Benchmark`, `Task`, `Evaluator`, `Rubric`, and criterion outcomes. +- `run_read_service.py`, `run_snapshot_read_model.py`, `communication_service.py`, and `evaluation_persistence_service.py` still import API-layer DTOs from `core/api/schemas.py`. Those are not beginner-facing authoring API objects, but the import direction is still awkward: runtime read-model code depends upward on API schemas. + +The revised target should be: public authoring API in `ergon_core.api`; internal composition in `core/composition`; runtime read models in a runtime/application read-model package; HTTP/API routers adapt those read models to wire schemas. + ### Error Types Are Not Domain-Local Some custom errors already exist under `core/runtime/errors`, for example graph, delegation, and Inngest-specific error modules. That is better than raising generic `ValueError` everywhere, but it still leaves service packages without local ownership of their failure modes. @@ -189,6 +238,7 @@ Graph node and edge status writes appear across: - `task_execution_service.py` - `task_propagation_service.py` +- `workflow_propagation_service.py` - `task_management_service.py` - `subtask_cancellation_service.py` - `subtask_blocking_service.py` @@ -240,7 +290,9 @@ These are not identical consumers, but the primitives overlap: load run graph, m `RunReadService` imports DTOs from `ergon_core.core.api.schemas` and imports `ergon_core.core.api.runs` helper functions inside `build_run_snapshot`. -That means a runtime service depends upward on API helpers. This is likely a layering smell. The pure DTO helper functions should either move into a runtime/read-model package, or the API should own the service and not call it "runtime". +That means a runtime service depends upward on API helpers. This is likely a layering smell. `run_snapshot_read_model.py` is a partial correction because it moves snapshot shaping into a named runtime read model, but it still imports DTO classes from `core/api/schemas.py`. The pure DTO helper functions and run snapshot DTOs should either move into a runtime/read-model package, or the API should own the service and not call it "runtime". + +The new public API nesting makes this more important. `ergon_core.api` should mean authoring API, not operational wire schemas. Runtime read models should not be coupled to the benchmark/worker/criterion authoring package or to HTTP schema modules. ### P3: Repeated Graph Repository Construction @@ -285,21 +337,57 @@ Some separation is legitimate, but the shared task identity payload should be ex ## Boundary Assessment -### Things That Belong Near Persistence +### Persistence Layer Boundary + +Keep `core/persistence` as storage infrastructure, not as a home for domain behavior. -These are schema or data-access concerns: +These belong in `core/persistence`: - SQLModel table definitions in `core/persistence`. - Shared DB session creation in `core/persistence/shared/db.py`. - Shared persisted enums and types in `core/persistence/shared`. -- Context and telemetry repositories that are mostly append/read/write around specific persisted rows. -- Definition persistence may be a better fit near `core/persistence/definitions` than under `runtime/services`. +- Thin append/read/write helpers that do not encode runtime policy. -Candidate to move or reframe: +These should move out of `core/persistence`, or should not be added there: + +- Domain repositories that encode graph/task/workflow/evaluation semantics. +- "Latest execution" selection rules. +- Graph lifecycle transition rules. +- Evaluation score aggregation semantics. +- Experiment-definition materialization from authored composition objects. + +In other words, `core/persistence` answers "what rows exist and how do we store them?" Domain packages answer "what does it mean to add a graph node, complete a task, select an attempt, or persist an authored experiment definition?" + +Candidate to split or dissolve: + +- `core/persistence/queries.py` + +It currently contains domain-shaped query objects (`DefinitionsQueries`, `TaskExecutionsQueries`, child-execution lookup, status lookup). Those should be redistributed over time into definition, task, graph, and read-model packages. + +Candidate to reframe: - `experiment_persistence_service.py` -It writes immutable experiment definition tables. It is not obviously a runtime orchestration service. +It writes immutable experiment definition tables, but the important behavior is not raw SQL persistence; it is materializing an authored `Experiment` into a persisted definition graph. That makes it a definition/composition domain operation that imports persistence table models, not a persistence-layer module. + +### Things That Belong Near Composition + +`Experiment` and `WorkerSpec` are now under `core/composition`, which gives the services refactor a better boundary than the original audit had. Composition owns the in-memory definition before it becomes persisted runtime state. + +Candidate to move or reframe: + +- `experiment_validation_service.py` + +It validates `Experiment`, benchmark task graph structure, evaluator bindings, and worker assignments. That is composition/definition validation, not runtime DAG execution. It can live under `runtime/services` temporarily, but the target should probably be `core/composition/validation.py` or `core/composition/services/validation.py` unless we decide all composition use cases belong under a broader `core/application` layer later. + +Related files that should be considered together: + +- `core/composition/experiment.py` +- `core/composition/worker_spec.py` +- `core/composition/handles.py` +- `runtime/services/experiment_validation_service.py` +- `runtime/services/experiment_persistence_service.py` +- `runtime/services/experiment_definition_service.py` ### Things That Belong In Runtime Domain Packages @@ -320,7 +408,7 @@ Candidate runtime packages: - `runtime/read_models` - `runtime/inngest/contracts` -The exact package names can wait for the refactor plan, but the target should be domain packages rather than one `services` bucket. +The exact package names can wait for the refactor plan, but the target should be domain packages rather than one `services` bucket. `workflow_propagation_service.py` should be treated as a graph lifecycle module during that migration, not as a generic workflow service. ### Things Inngest Should Own @@ -356,17 +444,25 @@ This is a sketch, not a final implementation plan. ```text core/runtime/ + # imports table/session infrastructure from core/persistence, + # but owns domain-specific persistence operations. + + composition_services/ # optional; may instead live under core/composition + validation.py # ExperimentValidationService or pure validation functions + graph/ models.py # runtime DTOs for graph snapshots and mutation records - repository.py # WorkflowGraphRepository + repository.py # WorkflowGraphRepository; domain-aware graph writes over persistence graph tables errors.py # graph structural and mutation errors traversal.py # subtree and dependency traversal primitives lookup.py # GraphNodeLookup or successor lifecycle.py # named graph status transitions, if introduced + propagation.py # former workflow_propagation_service graph edge/node propagation helpers tasks/ models.py # task execution commands/results, task refs errors.py # task execution/management/cancellation errors + repository.py # latest execution / attempt selection over RunTaskExecution rows execution.py # TaskExecutionService management.py # agent-initiated subtask operations inspection.py # read-only subtask snapshots @@ -379,6 +475,7 @@ core/runtime/ initialization.py finalization.py service.py # workflow navigation/resource materialization, if kept here + launch.py # ExperimentLaunchService if launch remains runtime-facing evaluation/ models.py @@ -391,6 +488,13 @@ core/runtime/ read_models/ errors.py run_snapshot.py # RunReadService and pure DTO shaping helpers + experiments.py # ExperimentReadService + cohorts.py # cohort read/detail/stats DTO shaping + + definitions/ + models.py # define/persist commands/results if kept out of persistence + definition.py # ExperimentDefinitionService + persistence.py # ExperimentPersistenceService; materializes composition objects into definition rows inngest/ client.py # Inngest singleton and cancellation config @@ -412,6 +516,18 @@ For Inngest specifically, avoid a separate top-level `runtime/inngest_client.py` ## High-Value Refactor Candidates +### 0. Keep The New Public API Boundary Out Of Runtime Read Models + +The public API is now an authoring API. Do not move run/cohort/graph/read-model concepts into `ergon_core.api` to make service imports easier. + +Immediate cleanup direction: + +- Leave `Benchmark`, `Task`, `Worker`, `Criterion`, `Rubric`, and their result/context objects in the nested public API packages. +- Keep `Experiment`, `WorkerSpec`, and definition handles in `core/composition`. +- Move operational DTO shaping out of `core/api/schemas.py` and into runtime/application read models before doing large package moves. + +This is mostly a boundary rule for the plan, but it prevents the services refactor from undoing the public API simplification. + ### 1. Extract Graph Traversal Primitives Create a small module for containment traversal by `parent_node_id`. @@ -471,14 +587,27 @@ Normalize naming inside any new package: ### 6. Decide Whether `WorkflowGraphRepository` Is A Repository Or Domain Service -Two defensible options: +Keep it in runtime, but move it to `runtime/graph/repository.py` and make clear that it is a domain repository for graph mutations, not a generic persistence repository. + +The repository writes audit mutations and encodes structural invariants, not just SQL CRUD. It should import `core/persistence/graph/models.py` table classes, but the operation names and invariants belong to the graph domain. -- Keep it in runtime, but move it to `runtime/graph/repository.py` and make clear that it is a domain repository for graph mutations, not a generic persistence repository. -- Move it nearer `persistence/graph`, but prevent it from depending on runtime dashboard/event DTOs. +Use this as the general persistence rule for the refactor: -The first option probably fits the current design better because the repository writes audit mutations and encodes structural invariants, not just SQL CRUD. +- Table definitions and session setup stay under `core/persistence`. +- Domain-specific repositories live with their domain package. +- Generic query bags such as `core/persistence/queries.py` should shrink or dissolve as their methods move to domain packages. -### 7. Move Inngest Ownership Into The Inngest Package +### 7. Move Experiment Validation Toward Composition + +`experiment_validation_service.py` is useful as a first extraction, but it should not make `runtime/services` the permanent home for composition validation. + +Candidate target: + +- `core/composition/validation.py` + +The target file can expose either `ExperimentValidationService` or pure validation functions. The important boundary is that this logic validates authored/composed definitions before persistence; it does not participate in live runtime execution. + +### 8. Move Inngest Ownership Into The Inngest Package Move or plan to move: @@ -490,7 +619,7 @@ Move or plan to move: This should be mostly import churn, but the plan should include architecture tests so Inngest setup does not drift back into `runtime/services`. -### 8. Add Domain-Local Error Modules +### 9. Add Domain-Local Error Modules As packages are split, add `errors.py` to each domain package. The first pass can be mechanical: @@ -503,25 +632,34 @@ The plan should not require inventing custom errors for every possible branch in ## Questions For The Refactor Plan -1. Should `services` disappear entirely in favor of domain packages, or should it remain as a compatibility import layer during migration? +1. Should `services` disappear entirely in favor of domain packages, or should it remain only for files not yet moved during direct bulk renames? 2. Should request/response models live in `models.py` beside each domain package, or in separate `contracts.py` files when they are consumed by Inngest/API boundaries? 3. Should `WorkflowGraphRepository` emit/listen to dashboard mutations directly, or should dashboard emission sit above the repository? 4. Should read-model services be considered runtime services, API services, or their own `runtime/read_models` layer? -5. Should definition persistence move under `persistence/definitions`, or stay in runtime because it converts authored experiments into persisted definition rows? +5. Which `core/persistence/queries.py` methods should dissolve into definition/task/graph/read-model domain repositories first? 6. Should each package expose its domain errors from `__init__.py`, or should callers import directly from `package.errors` to avoid new barrel behavior? 7. Should Inngest contracts be centralized in one `runtime/inngest/contracts.py`, or colocated with each function module? +8. Should `experiment_validation_service.py` move into `core/composition`, or should all experiment definition use cases live under a new definition/application package? +9. Should `workflow_propagation_service.py` become `runtime/graph/propagation.py`, or should propagation be split between graph lifecycle primitives and task orchestration? +10. Should operational DTOs currently in `core/api/schemas.py` move before or after the services package split? +11. Should the first domain repository extraction be `runtime/tasks/repository.py` for latest execution/attempt selection, since that duplication is already concrete? ## Recommended Next Step -Write a refactor plan that starts with mechanical, low-risk extractions before package moves: - -1. Extract shared latest-execution helper. -2. Extract graph traversal helper. -3. Extract evaluation score aggregation helper. -4. Move pure run snapshot helper functions out of `core.api.runs`. -5. Move Inngest client, registry, contracts, results, and errors under `runtime/inngest`. -6. Introduce domain package structure with one package at a time, starting with `runtime/graph`. -7. Add `errors.py` to each package as services move, and replace generic service-boundary exceptions where the domain already has a clear failure type. -8. Move/rename services only after tests prove the helpers preserve behavior. +Write a refactor plan that starts with mechanical, low-risk extractions before package moves. Revised order after the public API and service moves: + +1. Lock the boundary rule in tests: public `ergon_core.api` remains authoring-only; runtime/read-model services do not import beginner-facing API modules except at authoring/evaluation adapter boundaries. +2. Lock the persistence rule in tests or architecture notes: `core/persistence` owns tables/session/storage infrastructure; domain repositories live with runtime/composition/definition packages. +3. Extract shared latest-execution and attempt-selection logic into a task-domain repository/helper. +4. Extract graph containment traversal helper. +5. Move `workflow_propagation_service.py` behind a graph lifecycle module or package, preserving the current import behavior through direct bulk updates rather than aliasing. +6. Extract evaluation score aggregation helper. +7. Move pure run snapshot helper functions and operational DTO shaping out of `core.api.runs` / `core.api.schemas`. +8. Move `experiment_validation_service.py` toward `core/composition` and keep `experiment_persistence_service.py` in a definition/composition domain package rather than under raw persistence. +9. Move Inngest client, registry, contracts, results, and errors under `runtime/inngest`. +10. Introduce domain package structure with one package at a time, starting with `runtime/graph`. +11. Dissolve `core/persistence/queries.py` incrementally as each domain repository takes over its methods. +12. Add `errors.py` to each package as services move, and replace generic service-boundary exceptions where the domain already has a clear failure type. +13. Move/rename services only after tests prove the helpers preserve behavior. This order reduces risk because it fixes semantic duplication before large import churn. diff --git a/docs/superpowers/plans/2026-04-29-core-component-registry-refactor.md b/docs/superpowers/plans/2026-04-29-core-component-registry-refactor.md new file mode 100644 index 00000000..de57361b --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-core-component-registry-refactor.md @@ -0,0 +1,1229 @@ +# Core Component Registry Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move component registration ownership into `ergon_core` public API so core never imports `ergon_builtins`, builtins/tests explicitly register components, and experiment definition/runtime have a clear slug-to-component mental model. + +**Architecture:** Add a Pydantic-based `ComponentRegistry` and process-global `registry` under `ergon_core.api.registry`. Builtins, optional builtins capabilities, and tests contribute components through explicit registration functions. Core application/runtime code resolves persisted slugs through the core registry only. + +**Tech Stack:** Python, Pydantic models, pytest, Inngest job handlers, FastAPI startup, existing Ergon public APIs. + +--- + +## Mental Model To Preserve + +The final model should be easy to explain to students: + +1. Components are Python classes/functions: `Benchmark`, `Worker`, `Evaluator`/`Rubric`, `BaseSandboxManager`. +2. Registration says which component slugs are available in this process. +3. Experiment authoring passes concrete objects/specs into `Experiment`. +4. Persistence stores only stable identities: benchmark slug, worker slug, evaluator slug, sandbox slug, model target. +5. Runtime jobs turn those stored slugs back into Python classes/functions via `ergon_core.api.registry.registry`. + +The registry is not the main experiment authoring API. It is the catalog that validates slugs and rehydrates persisted definitions across process boundaries. + +## File Structure + +- Create `ergon_core/ergon_core/api/registry.py` + - Defines `WorkerFactory`, `ComponentRegistry`, `registry`, duplicate handling, `require_*` lookup helpers, and reset/snapshot helpers for tests. +- Modify `ergon_core/ergon_core/api/__init__.py` + - Re-export `ComponentRegistry`, `WorkerFactory`, and `registry`. +- Modify `ergon_builtins/ergon_builtins/registry_core.py` + - Replace exported dict ownership with `register_core_builtins(target=registry)`. +- Modify `ergon_builtins/ergon_builtins/registry_data.py` + - Replace exported dict ownership with `register_data_builtins(target=registry)`. +- Modify `ergon_builtins/ergon_builtins/registry_local_models.py` + - Replace exported dict ownership with `register_local_model_builtins(target=registry)` or a returned model backend mapping, depending on model backend constraints. +- Modify `ergon_builtins/ergon_builtins/registry.py` + - Becomes explicit composition function `register_builtins(target=registry)`. + - Optional: keep backwards-compatible module attributes temporarily only if necessary for existing tests, but core must not use them. +- Modify core runtime imports in: + - `ergon_core/ergon_core/core/application/jobs/worker_execute.py` + - `ergon_core/ergon_core/core/application/jobs/evaluate_task_run.py` + - `ergon_core/ergon_core/core/application/jobs/persist_outputs.py` + - `ergon_core/ergon_core/core/application/jobs/sandbox_setup.py` + - `ergon_core/ergon_core/core/application/experiments/launch.py` + - `ergon_core/ergon_core/core/application/experiments/service.py` + - `ergon_core/ergon_core/core/application/workflows/service.py` + - `ergon_core/ergon_core/core/application/tasks/management.py` + - `ergon_core/ergon_core/core/domain/experiments/worker_spec.py` + - `ergon_core/ergon_core/core/rest_api/app.py` +- Move test-only smoke fixture component definitions from: + - `ergon_core/ergon_core/test_support/smoke_fixtures/**` + - into `tests/e2e/fixtures/smoke_components/**` or `tests/fixtures/smoke_components/**`. +- Modify E2E/test startup: + - `tests/e2e/conftest.py` + - current startup plugin module(s) referenced by `ERGON_STARTUP_PLUGINS` + - tests currently importing `ergon_core.test_support.smoke_fixtures` +- Modify unit tests: + - `tests/unit/registry/test_builtin_pairings.py` + - add `tests/unit/registry/test_component_registry.py` + - add/adjust core tests that assert no `ergon_core` file imports `ergon_builtins.registry`. + +--- + +### Task 1: Add Core Public Component Registry + +**Files:** +- Create: `ergon_core/ergon_core/api/registry.py` +- Modify: `ergon_core/ergon_core/api/__init__.py` +- Test: `tests/unit/registry/test_component_registry.py` + +- [ ] **Step 1: Write failing registry unit tests** + +Create `tests/unit/registry/test_component_registry.py`: + +```python +import pytest + +from ergon_core.api import Benchmark, Rubric, Worker +from ergon_core.api.registry import ComponentRegistry +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager + + +class ExampleWorker(Worker): + type_slug = "example-worker" + + +class ReplacementWorker(Worker): + type_slug = "example-worker" + + +class ExampleBenchmark(Benchmark): + type_slug = "example-benchmark" + + +class ExampleRubric(Rubric): + type_slug = "example-rubric" + + +class ExampleSandboxManager(BaseSandboxManager): + pass + + +def test_registers_components_by_explicit_or_type_slug() -> None: + registry = ComponentRegistry() + + registry.register_worker(ExampleWorker.type_slug, ExampleWorker) + registry.register_benchmark(ExampleBenchmark) + registry.register_evaluator(ExampleRubric) + registry.register_sandbox_manager("example-benchmark", ExampleSandboxManager) + + assert registry.require_worker("example-worker") is ExampleWorker + assert registry.require_benchmark("example-benchmark") is ExampleBenchmark + assert registry.require_evaluator("example-rubric") is ExampleRubric + assert registry.sandbox_managers["example-benchmark"] is ExampleSandboxManager + + +def test_duplicate_slug_rejects_different_object() -> None: + registry = ComponentRegistry() + registry.register_worker("example-worker", ExampleWorker) + + with pytest.raises(ValueError, match="Duplicate worker slug 'example-worker'"): + registry.register_worker("example-worker", ReplacementWorker) + + +def test_duplicate_slug_allows_idempotent_registration() -> None: + registry = ComponentRegistry() + registry.register_worker("example-worker", ExampleWorker) + registry.register_worker("example-worker", ExampleWorker) + + assert registry.require_worker("example-worker") is ExampleWorker + + +def test_unknown_slug_error_lists_registered_values() -> None: + registry = ComponentRegistry() + registry.register_worker("example-worker", ExampleWorker) + + with pytest.raises( + ValueError, + match="Unknown worker slug 'missing-worker'; registered workers: example-worker", + ): + registry.require_worker("missing-worker") +``` + +- [ ] **Step 2: Run failing registry tests** + +Run: + +```bash +pytest tests/unit/registry/test_component_registry.py -q +``` + +Expected: FAIL because `ergon_core.api.registry` does not exist. + +- [ ] **Step 3: Implement `ergon_core.api.registry`** + +Create `ergon_core/ergon_core/api/registry.py`: + +```python +"""Public process-level component registry. + +The registry maps stable slugs stored in experiment definitions back to the +Python classes/factories needed by runtime jobs. Packages such as +``ergon_builtins`` and test fixtures contribute components explicitly during +startup; ``ergon_core`` never imports those packages to discover components. +""" + +from collections.abc import Callable, Mapping +from typing import TypeVar + +from ergon_core.api.benchmark import Benchmark +from ergon_core.api.rubric import Evaluator +from ergon_core.api.worker import Worker +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager +from pydantic import BaseModel, ConfigDict, Field + +WorkerFactory = Callable[..., Worker] +T = TypeVar("T") + + +class ComponentRegistry(BaseModel): + """Catalog of component types available in the current Python process.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + workers: dict[str, WorkerFactory] = Field(default_factory=dict) + benchmarks: dict[str, type[Benchmark]] = Field(default_factory=dict) + evaluators: dict[str, type[Evaluator]] = Field(default_factory=dict) + sandbox_managers: dict[str, type[BaseSandboxManager]] = Field(default_factory=dict) + + def register_worker(self, slug: str, factory: WorkerFactory) -> None: + self._register(self.workers, "worker", slug, factory) + + def register_benchmark(self, benchmark_cls: type[Benchmark], slug: str | None = None) -> None: + self._register(self.benchmarks, "benchmark", slug or benchmark_cls.type_slug, benchmark_cls) + + def register_evaluator(self, evaluator_cls: type[Evaluator], slug: str | None = None) -> None: + self._register(self.evaluators, "evaluator", slug or evaluator_cls.type_slug, evaluator_cls) + + def register_sandbox_manager( + self, + slug: str, + manager_cls: type[BaseSandboxManager], + ) -> None: + self._register(self.sandbox_managers, "sandbox manager", slug, manager_cls) + + def require_worker(self, slug: str) -> WorkerFactory: + return self._require(self.workers, "worker", slug) + + def require_benchmark(self, slug: str) -> type[Benchmark]: + return self._require(self.benchmarks, "benchmark", slug) + + def require_evaluator(self, slug: str) -> type[Evaluator]: + return self._require(self.evaluators, "evaluator", slug) + + def _register(self, target: dict[str, T], kind: str, slug: str, value: T) -> None: + existing = target.get(slug) + if existing is not None and existing is not value: + raise ValueError(f"Duplicate {kind} slug {slug!r}") + target[slug] = value + + def _require(self, target: Mapping[str, T], kind: str, slug: str) -> T: + try: + return target[slug] + except KeyError: + known = ", ".join(sorted(target)) or "" + raise ValueError( + f"Unknown {kind} slug {slug!r}; registered {kind}s: {known}" + ) from None + + +registry = ComponentRegistry() +``` + +- [ ] **Step 4: Re-export the registry from public API** + +Modify `ergon_core/ergon_core/api/__init__.py`: + +```python +"""Beginner-facing Ergon authoring API surface.""" + +from ergon_core.api.benchmark import Benchmark, BenchmarkRequirements, EmptyTaskPayload, Task +from ergon_core.api.criterion import ( + Criterion, + CriterionContext, + CriterionEvidence, + CriterionOutcome, + EvidenceMessage, + ScoreScale, +) +from ergon_core.api.errors import CriterionCheckError +from ergon_core.api.registry import ComponentRegistry, WorkerFactory, registry +from ergon_core.api.rubric import Rubric, TaskEvaluationResult +from ergon_core.api.worker import Worker, WorkerContext, WorkerOutput, WorkerStreamItem + +__all__ = [ + "Benchmark", + "BenchmarkRequirements", + "ComponentRegistry", + "Criterion", + "CriterionCheckError", + "CriterionContext", + "CriterionEvidence", + "CriterionOutcome", + "EmptyTaskPayload", + "EvidenceMessage", + "Rubric", + "ScoreScale", + "Task", + "TaskEvaluationResult", + "Worker", + "WorkerContext", + "WorkerFactory", + "WorkerOutput", + "WorkerStreamItem", + "registry", +] +``` + +- [ ] **Step 5: Run registry tests** + +Run: + +```bash +pytest tests/unit/registry/test_component_registry.py -q +``` + +Expected: PASS. + +--- + +### Task 2: Convert Builtins Registry To Explicit Registration + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/registry_core.py` +- Modify: `ergon_builtins/ergon_builtins/registry_data.py` +- Modify: `ergon_builtins/ergon_builtins/registry_local_models.py` +- Modify: `ergon_builtins/ergon_builtins/registry.py` +- Test: `tests/unit/registry/test_builtin_pairings.py` + +- [ ] **Step 1: Update builtin pairing tests to register into a fresh registry** + +Modify `tests/unit/registry/test_builtin_pairings.py` so tests no longer import dicts from `ergon_builtins.registry_core` or `ergon_builtins.registry`. Use a fresh `ComponentRegistry`: + +```python +"""Documented built-in benchmark pairings are explicit and registered.""" + +import pytest + +from ergon_core.api.registry import ComponentRegistry + + +CORE_PAIRINGS = [ + { + "benchmark": "minif2f", + "worker": "minif2f-react", + "evaluator": "minif2f-rubric", + "sandbox": "minif2f", + "extras": ("none",), + }, + { + "benchmark": "swebench-verified", + "worker": "swebench-react", + "evaluator": "swebench-rubric", + "sandbox": "swebench-verified", + "extras": ("ergon-builtins[data]",), + }, +] + +DATA_PAIRINGS = [ + { + "benchmark": "gdpeval", + "worker": "gdpeval-react", + "evaluator": "gdpeval-staged-rubric", + "sandbox": "gdpeval", + "extras": ("ergon-builtins[data]",), + }, + { + "benchmark": "researchrubrics", + "worker": "researchrubrics-researcher", + "evaluator": "researchrubrics-rubric", + "sandbox": "researchrubrics", + "extras": ("ergon-builtins[data]",), + }, + { + "benchmark": "researchrubrics-vanilla", + "worker": "researchrubrics-researcher", + "evaluator": "researchrubrics-rubric", + "sandbox": "researchrubrics-vanilla", + "extras": ("ergon-builtins[data]",), + }, +] + + +@pytest.mark.parametrize("pairing", CORE_PAIRINGS) +def test_core_pairings_reference_registered_slugs(pairing: dict[str, object]) -> None: + from ergon_builtins.registry_core import register_core_builtins + + registry = ComponentRegistry() + register_core_builtins(registry) + + _assert_pairing(pairing, registry) + + +@pytest.mark.parametrize("pairing", DATA_PAIRINGS) +def test_data_pairings_reference_registered_slugs(pairing: dict[str, object]) -> None: + pytest.importorskip("datasets", reason="ergon-builtins[data] not installed") + from ergon_builtins.registry import register_builtins + + registry = ComponentRegistry() + register_builtins(registry) + + _assert_pairing(pairing, registry) + + +def _assert_pairing(pairing: dict[str, object], registry: ComponentRegistry) -> None: + benchmark = pairing["benchmark"] + worker = pairing["worker"] + evaluator = pairing["evaluator"] + sandbox = pairing["sandbox"] + extras = pairing["extras"] + + assert benchmark in registry.benchmarks + assert worker in registry.workers + assert evaluator in registry.evaluators + assert sandbox in registry.sandbox_managers + assert isinstance(extras, tuple) + assert extras +``` + +- [ ] **Step 2: Run updated builtin pairing tests** + +Run: + +```bash +pytest tests/unit/registry/test_builtin_pairings.py -q +``` + +Expected: FAIL because the `register_*` functions do not exist. + +- [ ] **Step 3: Replace `registry_core.py` dicts with `register_core_builtins`** + +Modify `ergon_builtins/ergon_builtins/registry_core.py` to keep imports but replace exported dicts with: + +```python +from ergon_core.api.registry import ComponentRegistry, registry + + +def register_core_builtins(target: ComponentRegistry = registry) -> None: + """Register builtins that have no optional dependency extras.""" + + target.register_worker("training-stub", TrainingStubWorker) + target.register_worker("minif2f-react", minif2f_react) + target.register_worker("swebench-react", swebench_react) + + target.register_benchmark(MiniF2FBenchmark) + target.register_benchmark(SweBenchVerifiedBenchmark) + + target.register_evaluator(StagedRubric) + target.register_evaluator(StagedRubric, slug="gdpeval-staged-rubric") + target.register_evaluator(MiniF2FRubric) + target.register_evaluator(SWEBenchRubric) + + target.register_sandbox_manager("gdpeval", GDPEvalSandboxManager) + target.register_sandbox_manager("minif2f", MiniF2FSandboxManager) + target.register_sandbox_manager("swebench-verified", SWEBenchSandboxManager) +``` + +Do not remove `SANDBOX_TEMPLATES` yet unless all uses are known. Leave it as a plain exported mapping: + +```python +SANDBOX_TEMPLATES: dict[str, Path] = { + "minif2f": Path(__file__).parent / "benchmarks/minif2f/sandbox", + "swebench-verified": Path(__file__).parent / "benchmarks/swebench_verified/sandbox", +} +``` + +- [ ] **Step 4: Replace `registry_data.py` dicts with `register_data_builtins`** + +Modify `ergon_builtins/ergon_builtins/registry_data.py`: + +```python +from ergon_core.api.registry import ComponentRegistry, registry + + +def register_data_builtins(target: ComponentRegistry = registry) -> None: + """Register builtins that require the [data] optional dependency group.""" + + target.register_benchmark(GDPEvalBenchmark) + target.register_benchmark(ResearchRubricsBenchmark) + target.register_benchmark(ResearchRubricsVanillaBenchmark) + + target.register_evaluator(ResearchRubricsRubric, slug="research-rubric") + target.register_evaluator(ResearchRubricsRubric) + + target.register_worker("gdpeval-react", gdpeval_react) + target.register_worker(ResearchRubricsResearcherWorker.type_slug, ResearchRubricsResearcherWorker) + target.register_worker( + ResearchRubricsWorkflowCliReActWorker.type_slug, + ResearchRubricsWorkflowCliReActWorker, + ) + + target.register_sandbox_manager("researchrubrics", ResearchRubricsSandboxManager) + target.register_sandbox_manager("researchrubrics-vanilla", ResearchRubricsSandboxManager) +``` + +If `GDPEvalBenchmark` requires a sandbox manager but the current data registry does not register one, decide during implementation whether to add: + +```python +target.register_sandbox_manager("gdpeval", GDPEvalSandboxManager) +``` + +only if `GDPEvalSandboxManager` can be imported from the data module without creating an optional dependency problem. Otherwise keep the current core registration for `"gdpeval"`. + +- [ ] **Step 5: Convert top-level `ergon_builtins.registry` to an explicit registration function** + +Modify `ergon_builtins/ergon_builtins/registry.py`: + +```python +"""Register built-in Ergon components into the core public registry.""" + +import structlog + +from ergon_core.api.registry import ComponentRegistry, registry +from ergon_builtins.models.resolution import register_model_backend +from ergon_builtins.registry_core import register_core_builtins + +log = structlog.get_logger() + + +def register_builtins(target: ComponentRegistry = registry) -> None: + """Register builtins available in the current environment. + + This is intentionally explicit: importing ``ergon_core`` does not import + builtins, and importing builtins does not mutate core unless startup calls + this function. + """ + + register_core_builtins(target) + _register_local_model_builtins() + _register_data_builtins(target) + + +def _register_local_model_builtins() -> None: + try: + from ergon_builtins.registry_local_models import register_local_model_builtins + except ImportError: + log.info("ergon-builtins[local-models] not installed; local transformers inference unavailable") + return + + register_local_model_builtins() + + +def _register_data_builtins(target: ComponentRegistry) -> None: + try: + from ergon_builtins.registry_data import register_data_builtins + except ImportError: + log.info( + "ergon-builtins[data] not installed; gdpeval and researchrubrics benchmarks unavailable" + ) + return + + register_data_builtins(target) + + +INSTALL_HINTS: dict[str, str] = { + "transformers": "pip install 'ergon-builtins[local-models]'", + "gdpeval": "pip install 'ergon-builtins[data]'", + "researchrubrics": "pip install 'ergon-builtins[data]'", + "research-rubric": "pip install 'ergon-builtins[data]'", +} +``` + +- [ ] **Step 6: Convert local model registry** + +Modify `ergon_builtins/ergon_builtins/registry_local_models.py`: + +```python +"""Components that require the [local-models] capability.""" + +from ergon_builtins.models.resolution import register_model_backend +from ergon_builtins.models.transformers_backend import resolve_transformers + + +def register_local_model_builtins() -> None: + register_model_backend("transformers", resolve_transformers) +``` + +Keep core model backends registered wherever they are currently registered. If `registry_core.py` currently owns `"vllm"`, `"openai"`, `"anthropic"`, `"google"`, `"openrouter"`, and `"openai-responses"`, move that into a helper in `ergon_builtins.registry_core` called by `register_core_builtins()`: + +```python +def _register_core_model_backends() -> None: + register_model_backend("vllm", resolve_vllm) + register_model_backend("openai", resolve_cloud) + register_model_backend("anthropic", resolve_cloud) + register_model_backend("google", resolve_cloud) + register_model_backend("openrouter", resolve_openrouter) + register_model_backend("openai-responses", resolve_openrouter_responses) +``` + +- [ ] **Step 7: Run builtin registry tests** + +Run: + +```bash +pytest tests/unit/registry/test_builtin_pairings.py tests/unit/registry/test_component_registry.py -q +``` + +Expected: PASS. + +--- + +### Task 3: Add Startup Registration For Runtime Processes + +**Files:** +- Modify: runtime startup location that is imported by CLI/API before defining/running experiments. +- Likely modify: `ergon_core/ergon_core/core/rest_api/app.py` +- Search and modify: CLI entrypoints under `ergon_cli/**` +- Test: existing CLI/API tests that define experiments. + +- [ ] **Step 1: Locate CLI and startup entrypoints** + +Run: + +```bash +rg "experiment define|ERGON_STARTUP_PLUGINS|startup_plugins|register_builtins|def main|typer|click" ergon_cli ergon_core tests -n +``` + +Expected: identify the CLI initialization path and FastAPI lifespan path. + +- [ ] **Step 2: Add explicit builtin registration during API startup** + +In `ergon_core/ergon_core/core/rest_api/app.py`, import only the core registry at module or function scope. In the lifespan before sandbox event sink wiring, call builtins registration as a startup plugin decision: + +```python +from ergon_core.api.registry import registry + + +def _register_default_components() -> None: + from ergon_builtins.registry import register_builtins + + register_builtins(registry) +``` + +Then call `_register_default_components()` early in `lifespan`, before runtime services need sandbox managers. + +Important: this is acceptable at app startup because the application chooses to install builtins. Core library modules still must not import `ergon_builtins.registry`. + +- [ ] **Step 3: Update sandbox event sink wiring to use core registry** + +Replace: + +```python +from ergon_builtins.registry import SANDBOX_MANAGERS +... +for manager_cls in SANDBOX_MANAGERS.values(): + manager_cls.set_event_sink(sink) +logger.info("sandbox event sink wired on %d manager subclass(es)", 1 + len(SANDBOX_MANAGERS)) +``` + +with: + +```python +from ergon_core.api.registry import registry +... +for manager_cls in registry.sandbox_managers.values(): + manager_cls.set_event_sink(sink) +logger.info( + "sandbox event sink wired on %d manager subclass(es)", + 1 + len(registry.sandbox_managers), +) +``` + +- [ ] **Step 4: Add explicit builtin registration during CLI startup** + +In the CLI root entrypoint, add a small registration helper and call it before commands that define or run experiments: + +```python +from ergon_core.api.registry import registry + + +def register_default_components() -> None: + from ergon_builtins.registry import register_builtins + + register_builtins(registry) +``` + +Do not scatter this call through individual commands if there is a central CLI startup hook. If no central hook exists, call it at the top of experiment define/run command handlers and note the duplication for later cleanup. + +- [ ] **Step 5: Run fast CLI/API tests affected by startup** + +Run the narrowest available tests after locating them: + +```bash +pytest tests/unit tests/integration -q -k "experiment or registry or cli" +``` + +Expected: PASS or unrelated pre-existing failures documented before continuing. + +--- + +### Task 4: Replace Core Imports Of Builtins Registry + +**Files:** +- Modify listed core files containing `from ergon_builtins.registry import ...` +- Test: add import-boundary test under `tests/unit/registry/test_core_registry_boundary.py` + +- [ ] **Step 1: Add boundary test that core does not import builtins registry** + +Create `tests/unit/registry/test_core_registry_boundary.py`: + +```python +from pathlib import Path + + +def test_ergon_core_does_not_import_builtins_registry() -> None: + root = Path("ergon_core/ergon_core") + offenders: list[str] = [] + + for path in root.rglob("*.py"): + text = path.read_text() + if "ergon_builtins.registry" in text: + offenders.append(str(path)) + + assert offenders == [] +``` + +- [ ] **Step 2: Run boundary test and verify it fails** + +Run: + +```bash +pytest tests/unit/registry/test_core_registry_boundary.py -q +``` + +Expected: FAIL listing the current core files that import `ergon_builtins.registry`. + +- [ ] **Step 3: Update worker execution lookup** + +Modify `ergon_core/ergon_core/core/application/jobs/worker_execute.py`: + +```python +from ergon_core.api.registry import registry +``` + +Inside `run_worker_execute_job`, remove: + +```python +from ergon_builtins.registry import BENCHMARKS, WORKERS +``` + +Replace worker lookup: + +```python +worker_cls = registry.workers.get(payload.worker_type) +``` + +Replace benchmark lookup: + +```python +benchmark_cls = registry.benchmarks.get(payload.benchmark_type) +``` + +Keep existing `RegistryLookupError` behavior for workers by checking `None` as today. + +- [ ] **Step 4: Update evaluation job lookup** + +Modify `ergon_core/ergon_core/core/application/jobs/evaluate_task_run.py`: + +```python +from ergon_core.api.registry import registry +``` + +Remove the builtins import inside `run_evaluate_task_run_job`. Replace: + +```python +evaluator_cls = EVALUATORS.get(evaluator_type) +manager_cls = SANDBOX_MANAGERS.get(benchmark_type, DefaultSandboxManager) +benchmark_cls = BENCHMARKS.get(benchmark_type) if benchmark_type is not None else None +``` + +with: + +```python +evaluator_cls = registry.evaluators.get(evaluator_type) +manager_cls = ( + registry.sandbox_managers.get(benchmark_type, DefaultSandboxManager) + if benchmark_type is not None + else DefaultSandboxManager +) +benchmark_cls = registry.benchmarks.get(benchmark_type) if benchmark_type is not None else None +``` + +- [ ] **Step 5: Update sandbox and output jobs** + +Modify `ergon_core/ergon_core/core/application/jobs/persist_outputs.py` and `ergon_core/ergon_core/core/application/jobs/sandbox_setup.py`: + +```python +from ergon_core.api.registry import registry +``` + +Replace: + +```python +manager_cls = SANDBOX_MANAGERS.get(..., DefaultSandboxManager) +``` + +with: + +```python +manager_cls = registry.sandbox_managers.get(..., DefaultSandboxManager) +``` + +- [ ] **Step 6: Update experiment launch and define services** + +Modify `ergon_core/ergon_core/core/application/experiments/launch.py`: + +```python +from ergon_core.api.registry import registry +``` + +Replace evaluator and benchmark lookups with: + +```python +evaluator_cls = registry.require_evaluator(evaluator_slug) +source = registry.require_benchmark(benchmark_slug)() +``` + +Modify `ergon_core/ergon_core/core/application/experiments/service.py` so `_benchmark_cls` caches `registry.benchmarks`, not builtins dicts: + +```python +from ergon_core.api.registry import registry +... +if self._benchmarks is None: + self._benchmarks = registry.benchmarks +return self._benchmarks[benchmark_slug] +``` + +- [ ] **Step 7: Update workflow/task mutation validation** + +Modify `ergon_core/ergon_core/core/application/workflows/service.py`, `ergon_core/ergon_core/core/application/tasks/management.py`, and `ergon_core/ergon_core/core/domain/experiments/worker_spec.py`: + +```python +from ergon_core.api.registry import registry +``` + +Replace membership checks: + +```python +if slug not in WORKERS: +``` + +with: + +```python +if slug not in registry.workers: +``` + +For error messages listing known workers, use: + +```python +known = ", ".join(sorted(registry.workers)) +``` + +- [ ] **Step 8: Run boundary and affected unit tests** + +Run: + +```bash +pytest tests/unit/registry/test_core_registry_boundary.py tests/unit/registry/test_component_registry.py tests/unit/registry/test_builtin_pairings.py -q +``` + +Expected: PASS. + +--- + +### Task 5: Move Smoke Test Helpers Out Of Core + +**Files:** +- Move from: `ergon_core/ergon_core/test_support/smoke_fixtures/**` +- Move to: `tests/fixtures/smoke_components/**` +- Modify: `tests/e2e/conftest.py` +- Modify: startup plugin referenced by E2E environment +- Test: E2E smoke tests and import-boundary tests. + +- [ ] **Step 1: Add a test proving smoke fixtures do not live under core** + +Create or extend `tests/unit/registry/test_core_registry_boundary.py`: + +```python +def test_core_package_has_no_smoke_fixture_registration_package() -> None: + assert not Path("ergon_core/ergon_core/test_support/smoke_fixtures").exists() +``` + +Expected initially: FAIL. + +- [ ] **Step 2: Create tests fixture package** + +Create: + +```text +tests/fixtures/smoke_components/ +tests/fixtures/smoke_components/__init__.py +tests/fixtures/smoke_components/benchmarks.py +tests/fixtures/smoke_components/sandbox.py +tests/fixtures/smoke_components/criteria/ +tests/fixtures/smoke_components/workers/ +``` + +Move files from `ergon_core/ergon_core/test_support/smoke_fixtures/**` into the new package, preserving internal folder shape where possible. + +- [ ] **Step 3: Update imports in moved files** + +Search: + +```bash +rg "ergon_core\\.test_support\\.smoke_fixtures|test_support\\.smoke_fixtures" tests/fixtures/smoke_components tests ergon_core -n +``` + +Replace imports such as: + +```python +from ergon_core.test_support.smoke_fixtures.workers.swebench_smoke import SweBenchSmokeWorker +``` + +with: + +```python +from tests.fixtures.smoke_components.workers.swebench_smoke import SweBenchSmokeWorker +``` + +- [ ] **Step 4: Replace smoke registration function** + +In `tests/fixtures/smoke_components/__init__.py`, define: + +```python +"""Test-only smoke component registration.""" + +import os + +from ergon_core.api.registry import ComponentRegistry, registry +from tests.fixtures.smoke_components.benchmarks import ( + MiniF2FSmokeBenchmark, + ResearchRubricsSmokeBenchmark, + SweBenchSmokeBenchmark, +) +from tests.fixtures.smoke_components.criteria.smoke_rubrics import ( + MiniF2FSmokeRubric, + ResearchRubricsSmokeRubric, + SweBenchSmokeRubric, +) +from tests.fixtures.smoke_components.criteria.timing import SmokePostRootTimingRubric +from tests.fixtures.smoke_components.sandbox import SmokeSandboxManager +from tests.fixtures.smoke_components.workers.minif2f_smoke import ( + MiniF2FFailingLeafWorker, + MiniF2FRecursiveSmokeWorker, + MiniF2FSadPathSmokeWorker, + MiniF2FSmokeLeafWorker, + MiniF2FSmokeWorker, +) +from tests.fixtures.smoke_components.workers.researchrubrics_smoke import ( + ResearchRubricsFailingLeafWorker, + ResearchRubricsRecursiveSmokeWorker, + ResearchRubricsSadPathSmokeWorker, + ResearchRubricsSmokeLeafWorker, + ResearchRubricsSmokeWorker, +) +from tests.fixtures.smoke_components.workers.swebench_smoke import ( + SweBenchFailingLeafWorker, + SweBenchRecursiveSmokeWorker, + SweBenchSadPathSmokeWorker, + SweBenchSmokeLeafWorker, + SweBenchSmokeWorker, +) + + +def register_smoke_components(target: ComponentRegistry = registry) -> None: + """Register test-only smoke components into the supplied registry.""" + + if os.environ.get("ENABLE_TEST_HARNESS") == "1": + target.register_benchmark(ResearchRubricsSmokeBenchmark) + target.register_benchmark(MiniF2FSmokeBenchmark) + target.register_benchmark(SweBenchSmokeBenchmark) + target.register_sandbox_manager(ResearchRubricsSmokeBenchmark.type_slug, SmokeSandboxManager) + target.register_sandbox_manager(MiniF2FSmokeBenchmark.type_slug, SmokeSandboxManager) + target.register_sandbox_manager(SweBenchSmokeBenchmark.type_slug, SmokeSandboxManager) + + target.register_worker(ResearchRubricsSmokeWorker.type_slug, ResearchRubricsSmokeWorker) + target.register_worker(ResearchRubricsSmokeLeafWorker.type_slug, ResearchRubricsSmokeLeafWorker) + target.register_worker( + ResearchRubricsRecursiveSmokeWorker.type_slug, + ResearchRubricsRecursiveSmokeWorker, + ) + target.register_evaluator(ResearchRubricsSmokeRubric) + target.register_evaluator(SmokePostRootTimingRubric) + target.register_worker(ResearchRubricsSadPathSmokeWorker.type_slug, ResearchRubricsSadPathSmokeWorker) + target.register_worker(ResearchRubricsFailingLeafWorker.type_slug, ResearchRubricsFailingLeafWorker) + + target.register_worker(MiniF2FSmokeWorker.type_slug, MiniF2FSmokeWorker) + target.register_worker(MiniF2FSmokeLeafWorker.type_slug, MiniF2FSmokeLeafWorker) + target.register_worker(MiniF2FRecursiveSmokeWorker.type_slug, MiniF2FRecursiveSmokeWorker) + target.register_worker(MiniF2FSadPathSmokeWorker.type_slug, MiniF2FSadPathSmokeWorker) + target.register_worker(MiniF2FFailingLeafWorker.type_slug, MiniF2FFailingLeafWorker) + target.register_evaluator(MiniF2FSmokeRubric) + + target.register_worker(SweBenchSmokeWorker.type_slug, SweBenchSmokeWorker) + target.register_worker(SweBenchSmokeLeafWorker.type_slug, SweBenchSmokeLeafWorker) + target.register_worker(SweBenchRecursiveSmokeWorker.type_slug, SweBenchRecursiveSmokeWorker) + target.register_worker(SweBenchSadPathSmokeWorker.type_slug, SweBenchSadPathSmokeWorker) + target.register_worker(SweBenchFailingLeafWorker.type_slug, SweBenchFailingLeafWorker) + target.register_evaluator(SweBenchSmokeRubric) +``` + +- [ ] **Step 5: Update E2E startup plugin** + +Locate the startup plugin currently importing `ergon_core.test_support.smoke_fixtures`. Replace it with: + +```python +from tests.fixtures.smoke_components import register_smoke_components + + +def register() -> None: + register_smoke_components() +``` + +If the startup plugin loader expects a different function name, preserve that function name and call `register_smoke_components()` inside it. + +- [ ] **Step 6: Remove old core smoke fixture package** + +Delete `ergon_core/ergon_core/test_support/smoke_fixtures/**` only after all imports have been updated. + +- [ ] **Step 7: Run smoke fixture import and boundary tests** + +Run: + +```bash +pytest tests/unit/registry/test_core_registry_boundary.py -q +pytest tests/e2e/test_swebench_smoke.py --collect-only -q +``` + +Expected: PASS. + +--- + +### Task 6: Update E2E And Integration Tests To Use Explicit Registry Setup + +**Files:** +- Modify: `tests/e2e/conftest.py` +- Modify: E2E startup plugin module(s) +- Modify: tests currently using `ergon_builtins.registry` dict mutation +- Test: E2E smoke suite. + +- [ ] **Step 1: Search for remaining dict mutation against old registries** + +Run: + +```bash +rg "BENCHMARKS|WORKERS|EVALUATORS|SANDBOX_MANAGERS|ergon_builtins\\.registry|register_smoke_fixtures|smoke_fixtures" tests ergon_core ergon_builtins -n +``` + +Expected: remaining references are either in `ergon_builtins` registration implementation, tests asserting pairings via `ComponentRegistry`, or places to update. + +- [ ] **Step 2: Update tests that temporarily patch registries** + +Replace code like: + +```python +from ergon_builtins.registry import BENCHMARKS, SANDBOX_MANAGERS + +original_benchmarks = {slug: BENCHMARKS[slug] for slug in slugs} +BENCHMARKS[slug] = SmokeBenchmark +``` + +with fresh registry injection if the code under test accepts a registry, or explicit registration into global `registry` if the code under test is runtime-like: + +```python +from ergon_core.api.registry import registry + +registry.register_benchmark(SmokeBenchmark) +registry.register_sandbox_manager(SmokeBenchmark.type_slug, SmokeSandboxManager) +``` + +If a test mutates global `registry`, restore state in `finally`: + +```python +original_benchmarks = dict(registry.benchmarks) +original_sandbox_managers = dict(registry.sandbox_managers) +try: + registry.register_benchmark(SmokeBenchmark) + registry.register_sandbox_manager(SmokeBenchmark.type_slug, SmokeSandboxManager) + ... +finally: + registry.benchmarks.clear() + registry.benchmarks.update(original_benchmarks) + registry.sandbox_managers.clear() + registry.sandbox_managers.update(original_sandbox_managers) +``` + +- [ ] **Step 3: Keep host-side E2E black-box behavior** + +`tests/e2e/conftest.py` currently documents that smoke fixture registration lives in the API container via `ERGON_STARTUP_PLUGINS`. Keep that mental model. Update the note to reference `tests.fixtures.smoke_components.register_smoke_components`, not `ergon_core.test_support`. + +- [ ] **Step 4: Run E2E smoke collect and selected tests** + +Run: + +```bash +pytest tests/e2e/test_swebench_smoke.py --collect-only -q +``` + +Then, if the E2E stack is running: + +```bash +pytest tests/e2e/test_swebench_smoke.py -q +``` + +Expected: collect passes. Runtime E2E passes when required infrastructure is available. + +--- + +### Task 7: Improve Experiment Validation Error Messages + +**Files:** +- Modify: `ergon_core/ergon_core/core/domain/experiments/worker_spec.py` +- Modify: `ergon_core/ergon_core/core/domain/experiments/validation.py` +- Test: existing or new experiment validation unit tests. + +- [ ] **Step 1: Add tests for clear missing component errors** + +Create or update `tests/unit/experiments/test_experiment_validation.py` with tests covering: + +```python +import pytest + +from ergon_core.core.domain.experiments import WorkerSpec + + +def test_worker_spec_unknown_worker_lists_registered_workers() -> None: + spec = WorkerSpec(worker_slug="missing-worker", name="primary", model="stub:constant") + + with pytest.raises(ValueError, match="Unknown worker slug 'missing-worker'"): + spec.validate_spec() +``` + +If the registry is process-global and other tests register workers, isolate this test by snapshotting/restoring `registry.workers`. + +- [ ] **Step 2: Update `WorkerSpec.validate_spec`** + +Use `ergon_core.api.registry.registry`: + +```python +from ergon_core.api.registry import registry + + +def validate_spec(self) -> None: + """Check that ``worker_slug`` refers to a known registry entry.""" + if self.worker_slug not in registry.workers: + known = ", ".join(sorted(registry.workers)) or "" + raise ValueError( + f"Unknown worker slug {self.worker_slug!r}; registered workers: {known}" + ) + if not self.name: + raise ValueError("WorkerSpec.name must be a non-empty string") + if not self.model: + raise ValueError("WorkerSpec.model must be a non-empty string") +``` + +- [ ] **Step 3: Add benchmark pairing metadata only if needed** + +Do not add a large new abstraction in this refactor unless tests show a concrete gap. If student-facing validation needs “benchmark X expects worker Y,” add a small optional method to benchmark classes later: + +```python +def recommended_worker_slugs(self) -> tuple[str, ...]: + return () +``` + +For this plan, keep pairing validation in tests and docs unless an existing runtime path requires it. + +- [ ] **Step 4: Run experiment validation tests** + +Run: + +```bash +pytest tests/unit -q -k "validation or WorkerSpec or registry" +``` + +Expected: PASS. + +--- + +### Task 8: Final Search, Lint, And Regression Verification + +**Files:** +- No planned source files beyond cleanup. + +- [ ] **Step 1: Verify no core imports of builtins registry remain** + +Run: + +```bash +rg "ergon_builtins\\.registry" ergon_core/ergon_core -n +``` + +Expected: no matches. + +- [ ] **Step 2: Verify old smoke fixture location is gone** + +Run: + +```bash +test ! -d ergon_core/ergon_core/test_support/smoke_fixtures +``` + +Expected: exit code 0. + +- [ ] **Step 3: Verify remaining registry references are intentional** + +Run: + +```bash +rg "BENCHMARKS|WORKERS|EVALUATORS|SANDBOX_MANAGERS" ergon_core ergon_builtins tests -n +``` + +Expected: no core runtime imports from `ergon_builtins.registry`; remaining uppercase dict names should either be deleted or constrained to docs/backwards compatibility tests. + +- [ ] **Step 4: Run focused tests** + +Run: + +```bash +pytest tests/unit/registry -q +pytest tests/unit -q -k "experiment or workflow or task or sandbox or registry" +``` + +Expected: PASS. + +- [ ] **Step 5: Run E2E collect** + +Run: + +```bash +pytest tests/e2e --collect-only -q +``` + +Expected: PASS. + +- [ ] **Step 6: Run full available test suite** + +Run: + +```bash +pytest tests/unit -q +``` + +Expected: PASS. If E2E infrastructure is available, also run: + +```bash +pytest tests/e2e -q +``` + +Expected: PASS or documented infrastructure failures unrelated to this refactor. + +--- + +## Self-Review + +- Spec coverage: The plan covers core registry creation, builtins update, removal of `BENCHMARKS`/`WORKERS`/`EVALUATORS`/`SANDBOX_MANAGERS` imports from core, moving smoke test helpers out of core, and updating integration/E2E registration flow. +- Placeholder scan: No unfinished placeholder markers remain. The only conditional areas are explicitly bounded implementation checks where the current codebase must be searched first, such as CLI entrypoint location and optional data dependency import constraints. +- Type consistency: `ComponentRegistry`, `WorkerFactory`, `registry`, and `register_*` function names are used consistently across tasks. diff --git a/docs/superpowers/plans/2026-04-29-finish-builtins-cli-e2e-refactor.md b/docs/superpowers/plans/2026-04-29-finish-builtins-cli-e2e-refactor.md new file mode 100644 index 00000000..78a06932 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-finish-builtins-cli-e2e-refactor.md @@ -0,0 +1,841 @@ +# Finish Built-ins, CLI, And E2E Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the Ergon built-ins, CLI, and e2e refactor after the core public API and test-support facade have stabilized, while avoiding private core internals that may continue moving. + +**Architecture:** Treat `ergon_core.api`, core service/facade DTOs, `ergon_core.test_support`, HTTP `/api/test/*`, and application read models as the stable boundary. Production built-ins own benchmark-specific workers/rubrics/sandboxes; CLI commands validate explicit slugs and call core facades; e2e tests assert black-box runtime behavior and use test-support constants rather than private repository methods. + +**Tech Stack:** Python, pytest, FastAPI test harness endpoints, Playwright, Inngest, E2B, `ergon_core.test_support`, `ergon_builtins.registry`, `ergon_cli`. + +--- + +## Current Working Assumptions + +- Core runtime behavior is stable: the canonical smoke topology, resource counts, task states, communication threads, and evaluation outcomes are still expected to match existing e2e assertions. +- Core internal layout has changed substantially. Tests should not import private repository modules or persistence models unless there is no stable public/test-support read helper yet. +- `ergon_core.test_support` is stable and may be imported by unit/integration/e2e host-side test code. +- The API process, not the host e2e process, should register smoke fixtures via startup plugin/environment. +- Built-ins and CLI work may proceed as long as it stays on public API/service boundaries and avoids core repository implementation files. + +## E2E Behavior That Should Remain True + +These expected values are derived from stable smoke fixture constants and should remain hard assertions unless `ergon_core.test_support.smoke_fixtures` changes intentionally. + +```text +Happy path: +- 12 total tasks: 1 root + 9 direct subtasks + 2 nested subtasks +- 10 leaf tasks +- direct level-1 slugs match EXPECTED_SUBTASK_SLUGS +- nested level-2 slugs match NESTED_LINE_SLUGS +- l_2 is non-leaf; l_2_a and l_2_b are children of l_2 +- all nodes complete +- 20 task artifact resources: 10 benchmark artifacts + 10 probe_*.json +- no worker_output resources; final assistant messages stay on executions +- 26 context events: parent 3 + recursive 3 + 10 leaves x 2 +- 2 root evaluations, both score 1.0, created after root execution completion +- final score is 1.0 +- one smoke-completion thread with 11 ordered messages + +Sad path: +- l_2 fails +- l_3 is blocked, never starts, and has no execution attempts +- root does not complete +- independent leaves complete +- exactly one partial_*.md artifact persists from l_2 +- at least one pre-failure partial wc WAL/probe entry exists +- smoke-completion thread has 7 messages +- l_2 and l_3 do not send completion messages +- final score is None or 0.0 +``` + +Benchmark-specific artifact assertions should also remain: + +```text +MiniF2F: +- 10 proof_*.lean resources +- each proof contains "theorem smoke_trivial" and ":=" + +SWE-Bench: +- 10 patch_*.py resources +- each patch parses as Python and defines add() + +ResearchRubrics: +- report/probe artifacts and dashboard-visible resource panels match the shared smoke assertions +``` + +## File Responsibility Map + +Built-ins: + +- `ergon_builtins/ergon_builtins/registry.py`: merged public registry surface. +- `ergon_builtins/ergon_builtins/registry_core.py`: always-importable benchmarks/workers/evaluators/sandboxes/model backends. +- `ergon_builtins/ergon_builtins/registry_data.py`: `[data]` benchmark registrations. +- `ergon_builtins/ergon_builtins/benchmarks/*/worker_factory.py`: benchmark-owned worker factories or benchmark-owned re-export surfaces. +- `ergon_builtins/ergon_builtins/shared/`: generic worker, criteria, model, prompt import surfaces. + +CLI: + +- `ergon_cli/ergon_cli/main.py`: parser contract only. +- `ergon_cli/ergon_cli/commands/experiment.py`: thin command handler for `experiment define/run/show/list`. +- `ergon_cli/ergon_cli/commands/benchmark.py`: `list`, `setup`, and `run` wrapper behavior. +- `ergon_cli/ergon_cli/discovery/__init__.py`: registry list helpers. +- Future target: `ergon_cli/ergon_cli/services/*_facade.py` if command handlers remain too stateful. + +E2E: + +- `tests/e2e/_submit.py`: black-box cohort submission client for `/api/test/write/cohort`. +- `tests/e2e/_read_contracts.py`: stable read-model wrapper for run snapshots. +- `tests/e2e/_asserts.py`: behavior assertions; should import test-support constants and stable read helpers. +- `tests/e2e/test_{researchrubrics,minif2f,swebench}_smoke.py`: per-benchmark e2e drivers. +- `ergon-dashboard/tests/e2e/*.smoke.spec.ts`: dashboard assertions. + +Stable core/test-support surfaces: + +- `ergon_core.api` +- `ergon_core.test_support` +- `ergon_core.core.application.read_models.*`, if accepted as the application-level read facade +- `/api/test/*` HTTP endpoints + +Private core surfaces to avoid in new e2e code: + +- `ergon_core.core.persistence.*` models and queries +- `ergon_core.core.runtime.tasks.repository` +- `ergon_core.core.runtime.evaluation.persistence` +- Inngest child payload modules +- repository method names or table-specific access patterns + +## Task 1: Freeze And Document The Stable E2E Boundary + +**Files:** +- Modify: `docs/superpowers/plans/2026-04-28-ergon-e2e-refactor-test-plan.md` +- Test: `tests/unit/architecture/test_public_api_boundaries.py` + +- [ ] **Step 1: Add a “stable e2e boundary” section to the e2e plan** + +Add this section near the existing `Fixture Residency Rules` section: + +```markdown +## Stable E2E Boundary After Core Layout Refactor + +Core behavior is stable, but private repository and persistence modules may move. +E2E code should use only: + +- HTTP endpoints under `/api/test/*` +- `ergon_core.test_support` +- public core API objects from `ergon_core.api` +- application read-model facades, not private repository methods + +The existing smoke behavior assertions remain valid: + +- happy runs complete the 12-node graph +- sad runs fail `l_2` and block `l_3` +- happy runs produce 20 task resources and 26 context events +- happy root produces two score-1.0 evaluations +- sad runs produce one partial artifact and seven completion messages +``` + +- [ ] **Step 2: Add or update a boundary test** + +Add/extend a test in `tests/unit/architecture/test_public_api_boundaries.py`: + +```python +from pathlib import Path + + +def test_e2e_tests_do_not_import_private_core_repositories() -> None: + e2e_dir = Path("tests/e2e") + forbidden = ( + "ergon_core.core.persistence.", + "ergon_core.core.runtime.tasks.repository", + "ergon_core.core.runtime.evaluation.persistence", + "ergon_core.core.runtime.inngest.", + ) + offenders: list[tuple[str, str]] = [] + for path in e2e_dir.rglob("*.py"): + text = path.read_text() + for needle in forbidden: + if needle in text: + offenders.append((str(path), needle)) + assert not offenders +``` + +- [ ] **Step 3: Run the boundary test and confirm failure before cleanup** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_public_api_boundaries.py::test_e2e_tests_do_not_import_private_core_repositories -q +``` + +Expected before cleanup: fail with current `tests/e2e/_asserts.py` private persistence imports. + +## Task 2: Update E2E Submission To Explicit Runtime Choices + +**Files:** +- Modify: `tests/e2e/_submit.py` +- Modify: `tests/e2e/test_researchrubrics_smoke.py` +- Modify: `tests/e2e/test_minif2f_smoke.py` +- Modify: `tests/e2e/test_swebench_smoke.py` +- Test: `tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py` + +- [ ] **Step 1: Add a unit test for explicit e2e submission payloads** + +Create or update `tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py`: + +```python +from tests.e2e._submit import build_cohort_payload + + +def test_build_cohort_payload_includes_explicit_runtime_choices() -> None: + payload = build_cohort_payload( + benchmark_slug="minif2f", + slots=[("minif2f-smoke-worker", "minif2f-smoke-criterion")], + cohort_key="ci-smoke-minif2f", + sandbox_slug="minif2f", + dependency_extras=("none",), + model="openai:gpt-4o", + ) + + assert payload["benchmark_slug"] == "minif2f" + assert payload["sandbox_slug"] == "minif2f" + assert payload["dependency_extras"] == ["none"] + assert payload["model"] == "openai:gpt-4o" + assert payload["slots"] == [ + { + "worker_slug": "minif2f-smoke-worker", + "evaluator_slug": "minif2f-smoke-criterion", + } + ] +``` + +- [ ] **Step 2: Implement `build_cohort_payload()`** + +In `tests/e2e/_submit.py`, add: + +```python +def build_cohort_payload( + *, + benchmark_slug: str, + slots: list[tuple[str, str]], + cohort_key: str, + sandbox_slug: str, + dependency_extras: tuple[str, ...], + model: str = "openai:gpt-4o", +) -> dict: + return { + "benchmark_slug": benchmark_slug, + "slots": [ + {"worker_slug": worker, "evaluator_slug": evaluator} + for worker, evaluator in slots + ], + "cohort_key": cohort_key, + "sandbox_slug": sandbox_slug, + "dependency_extras": list(dependency_extras), + "model": model, + } +``` + +- [ ] **Step 3: Route `submit_cohort()` through the payload builder** + +Change `submit_cohort()` signature to accept explicit fields: + +```python +async def submit_cohort( + *, + benchmark_slug: str, + slots: list[tuple[str, str]], + cohort_key: str, + sandbox_slug: str, + dependency_extras: tuple[str, ...], + model: str = "openai:gpt-4o", + timeout: int = 300, +) -> list[UUID]: + payload = build_cohort_payload( + benchmark_slug=benchmark_slug, + slots=slots, + cohort_key=cohort_key, + sandbox_slug=sandbox_slug, + dependency_extras=dependency_extras, + model=model, + ) + async with httpx.AsyncClient(base_url=_api_base(), timeout=30.0) as client: + response = await client.post("/api/test/write/cohort", json=payload) + ... +``` + +- [ ] **Step 4: Update each e2e driver call** + +For `tests/e2e/test_minif2f_smoke.py`: + +```python +run_ids = await submit_cohort( + benchmark_slug=ENV, + slots=[(worker, criterion) for _, worker, criterion in smoke_slots], + cohort_key=cohort_key, + sandbox_slug=ENV, + dependency_extras=("none",), + timeout=PER_RUN_TIMEOUT, +) +``` + +For `tests/e2e/test_swebench_smoke.py`: + +```python +run_ids = await submit_cohort( + benchmark_slug=ENV, + slots=[(worker, criterion) for _, worker, criterion in smoke_slots], + cohort_key=cohort_key, + sandbox_slug=ENV, + dependency_extras=("none",), + timeout=PER_RUN_TIMEOUT, +) +``` + +For `tests/e2e/test_researchrubrics_smoke.py`: + +```python +run_ids = await submit_cohort( + benchmark_slug=ENV, + slots=[(worker, criterion) for _, worker, criterion in smoke_slots], + cohort_key=cohort_key, + sandbox_slug=ENV, + dependency_extras=("none",), + timeout=PER_RUN_TIMEOUT, +) +``` + +Smoke fixtures replace production benchmark loaders, so e2e smoke should use `("none",)` unless the API harness explicitly requires package extras to test onboarding messaging. + +- [ ] **Step 5: Run unit payload test** + +Run: + +```bash +uv run pytest tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py -q +``` + +Expected: pass. + +## Task 3: Replace Private E2E Reads With Test-Support Or Application Read Models + +**Files:** +- Modify: `tests/e2e/_asserts.py` +- Modify: `tests/e2e/_read_contracts.py` +- Optional create: `ergon_core/ergon_core/test_support/e2e_read_helpers.py` +- Test: `tests/unit/smoke_base/test_e2e_read_helpers.py` + +- [ ] **Step 1: Inventory direct private imports in `_asserts.py`** + +Search: + +```bash +rg "ergon_core.core.persistence|sqlmodel|select\\(" tests/e2e/_asserts.py +``` + +Expected current private access areas: + +- graph node rows for temporal ordering +- `RunResource` rows for blob/artifact assertions +- `RunTaskEvaluation` rows for evaluation timestamp assertions +- sandbox WAL/event rows + +- [ ] **Step 2: Keep `require_run_snapshot()` as the primary read path** + +`tests/e2e/_read_contracts.py` may keep: + +```python +from ergon_core.core.application.read_models.models import RunSnapshotDto +from ergon_core.core.application.read_models.runs import RunReadService +``` + +Do not import private repository classes in e2e drivers. If `RunReadService` moves, fix this wrapper only. + +- [ ] **Step 3: Add test-support helpers only for data not exposed in snapshots** + +If WAL/resource byte paths/evaluation timestamps are not exposed through `RunSnapshotDto`, create `ergon_core/ergon_core/test_support/e2e_read_helpers.py`: + +```python +"""Stable test-support reads for e2e assertions.""" + +from pathlib import Path +from uuid import UUID + +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.persistence.telemetry.models import ( + RunResource, + RunTaskEvaluation, + RunTaskExecution, + SandboxCommandWalEntry, + SandboxEvent, +) +from sqlmodel import select + + +def list_run_resources(run_id: UUID) -> list[RunResource]: + with get_session() as session: + return list(session.exec(select(RunResource).where(RunResource.run_id == run_id)).all()) + + +def read_resource_bytes(resource: RunResource) -> bytes: + return Path(resource.file_path).read_bytes() + + +def list_sandbox_command_wal(run_id: UUID) -> list[SandboxCommandWalEntry]: + with get_session() as session: + return list( + session.exec( + select(SandboxCommandWalEntry).where(SandboxCommandWalEntry.run_id == run_id), + ).all() + ) + + +def list_sandbox_events(run_id: UUID) -> list[SandboxEvent]: + with get_session() as session: + return list(session.exec(select(SandboxEvent).where(SandboxEvent.run_id == run_id)).all()) + + +def list_root_evaluation_rows(run_id: UUID) -> tuple[RunTaskExecution | None, list[RunTaskEvaluation]]: + # Implementation may use the current core layout internally. + # E2E tests should import this function, not the private models directly. + ... +``` + +If the core agent has already created stable equivalents under `ergon_core.test_support`, use those instead of adding this file. + +- [ ] **Step 4: Move `_asserts.py` imports to stable helper functions** + +Change `tests/e2e/_asserts.py` so private persistence imports are replaced by: + +```python +from ergon_core.test_support.e2e_read_helpers import ( + list_root_evaluation_rows, + list_run_resources, + list_sandbox_command_wal, + list_sandbox_events, + read_resource_bytes, +) +``` + +Keep these direct test-support imports: + +```python +from ergon_core.test_support.smoke_fixtures.smoke_base.constants import EXPECTED_SUBTASK_SLUGS +from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker +from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import ( + NESTED_LINE_SLUGS, + RecursiveSmokeWorkerBase, +) +from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase +``` + +- [ ] **Step 5: Re-run the boundary test** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_public_api_boundaries.py::test_e2e_tests_do_not_import_private_core_repositories -q +``` + +Expected after cleanup: pass. + +## Task 4: Finish Built-ins Registry And Factory Contracts + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/registry_core.py` +- Modify: `ergon_builtins/ergon_builtins/registry_data.py` +- Modify/create: `ergon_builtins/ergon_builtins/benchmarks/gdpeval/worker_factory.py` +- Modify/create: `ergon_builtins/ergon_builtins/benchmarks/researchrubrics/worker_factory.py` +- Modify: `tests/unit/registry/test_builtin_pairings.py` +- Modify: `tests/unit/registry/test_react_factories.py` + +- [ ] **Step 1: Verify explicit pairing table** + +`tests/unit/registry/test_builtin_pairings.py` must contain registered pairings: + +```python +PAIRINGS = [ + ("minif2f", "minif2f-react", "minif2f-rubric", "minif2f", ("none",)), + ("swebench-verified", "swebench-react", "swebench-rubric", "swebench-verified", ("none",)), + ("gdpeval", "gdpeval-react", "gdpeval-staged-rubric", "gdpeval", ("ergon-builtins[data]",)), + ("researchrubrics", "researchrubrics-researcher", "researchrubrics-rubric", "researchrubrics", ("ergon-builtins[data]",)), + ("researchrubrics-vanilla", "researchrubrics-researcher", "researchrubrics-rubric", "researchrubrics-vanilla", ("ergon-builtins[data]",)), +] +``` + +Use `("none",)` for e2e smoke replacement submissions, but keep production pairing documentation accurate for production data benchmarks. + +- [ ] **Step 2: Register final evaluator slugs** + +`registry_core.py` should expose both during migration: + +```python +EVALUATORS = { + "staged-rubric": StagedRubric, + "gdpeval-staged-rubric": StagedRubric, + ... +} +``` + +`registry_data.py` should expose: + +```python +EVALUATORS = { + "research-rubric": ResearchRubricsRubric, + "researchrubrics-rubric": ResearchRubricsRubric, +} +``` + +- [ ] **Step 3: Keep benchmark-owned worker factory surfaces** + +Required files: + +```text +ergon_builtins/ergon_builtins/benchmarks/minif2f/worker_factory.py +ergon_builtins/ergon_builtins/benchmarks/swebench_verified/worker_factory.py +ergon_builtins/ergon_builtins/benchmarks/gdpeval/worker_factory.py +ergon_builtins/ergon_builtins/benchmarks/researchrubrics/worker_factory.py +``` + +`researchrubrics/worker_factory.py` may re-export existing worker classes until a later physical move. + +- [ ] **Step 4: Run registry tests** + +Run: + +```bash +uv run pytest tests/unit/registry/test_builtin_pairings.py tests/unit/registry/test_react_factories.py -q +``` + +Expected: pass. + +## Task 5: Finish CLI Contract And Wrapper Behavior + +**Files:** +- Modify: `ergon_cli/ergon_cli/main.py` +- Modify: `ergon_cli/ergon_cli/commands/experiment.py` +- Modify: `ergon_cli/ergon_cli/commands/benchmark.py` +- Modify: `tests/unit/cli/test_experiment_cli.py` +- Modify: `tests/unit/cli/test_benchmark_setup.py` + +- [ ] **Step 1: Keep explicit define args required** + +Parser requirements: + +```text +ergon experiment define + --worker + --model + --evaluator + --sandbox + --extras +``` + +Test with: + +```bash +uv run pytest tests/unit/cli/test_experiment_cli.py::test_experiment_define_requires_explicit_runtime_choices -q +``` + +- [ ] **Step 2: Keep `benchmark run` as define-plus-run wrapper** + +`benchmark run` should parse the same explicit fields: + +```text +ergon benchmark run + --limit 1 + --worker + --model + --evaluator + --sandbox + --extras +``` + +If `ExperimentLaunchService.wait/timeout_seconds` is not implemented, do not expose `--timeout` or `--no-wait` on `benchmark run`. The wrapper should submit and print run IDs, not pretend to block. + +- [ ] **Step 3: Keep `benchmark setup` success hint explicit** + +Expected hint shape: + +```text +ergon benchmark run --limit 1 --worker --model --evaluator --sandbox --extras none +``` + +Regression test: + +```python +def test_setup_success_hint_uses_explicit_runtime_choices(...): + rc = setup_benchmark(_make_args()) + out = capsys.readouterr().out + assert "--worker" in out + assert "--evaluator" in out + assert "--sandbox" in out + assert "--extras" in out +``` + +- [ ] **Step 4: Run CLI tests** + +Run: + +```bash +uv run pytest tests/unit/cli/test_experiment_cli.py tests/unit/cli/test_benchmark_setup.py -q +``` + +Expected: pass. + +## Task 6: Align `/api/test/write/cohort` With Explicit Test Harness Contract + +**Files:** +- Modify: `ergon_core/ergon_core/core/api/test_harness.py` or the current stable test harness module if moved +- Modify: `tests/integration/smokes/test_smoke_harness.py` +- Modify: `tests/e2e/_submit.py` + +- [ ] **Step 1: Ensure request DTO accepts explicit sandbox/extras** + +The stable test harness write request should accept: + +```python +class SubmitCohortRequest(BaseModel): + benchmark_slug: str + slots: list[CohortSlotRequest] + cohort_key: str + sandbox_slug: str | None = None + dependency_extras: tuple[str, ...] = ("none",) + model: str = "openai:gpt-4o" + limit: int = 1 +``` + +- [ ] **Step 2: Ensure the harness uses the same define/run service path** + +The handler should pass: + +```python +ExperimentDefineRequest( + benchmark_slug=body.benchmark_slug, + cohort_id=cohort.id, + limit=body.limit, + default_model_target=body.model, + default_worker_team={"primary": slot.worker_slug}, + default_evaluator_slug=slot.evaluator_slug, + sandbox_slug=body.sandbox_slug or body.benchmark_slug, + dependency_extras=body.dependency_extras, + metadata={"source": "test-harness"}, +) +``` + +If the core facade DTO names differ after the core refactor, adapt to the stable facade shape rather than private repositories. + +- [ ] **Step 3: Add integration assertion** + +In `tests/integration/smokes/test_smoke_harness.py`, assert the write endpoint accepts a payload with `sandbox_slug` and `dependency_extras` and returns run IDs. + +- [ ] **Step 4: Run smoke harness integration test** + +Run: + +```bash +uv run pytest tests/integration/smokes/test_smoke_harness.py -q +``` + +Expected: pass if stack dependencies for integration are available; otherwise skip should be environment-gated. + +## Task 7: Preserve E2E Runtime Assertions While Updating Access Paths + +**Files:** +- Modify: `tests/e2e/_asserts.py` +- Modify: `tests/e2e/test_researchrubrics_smoke.py` +- Modify: `tests/e2e/test_minif2f_smoke.py` +- Modify: `tests/e2e/test_swebench_smoke.py` +- Modify: `ergon-dashboard/tests/e2e/*.smoke.spec.ts` + +- [ ] **Step 1: Keep the behavioral assertions hard** + +Do not weaken these assertions: + +```python +assert snapshot.total_tasks == 12 +assert snapshot.total_leaf_tasks == 10 +assert len(probes) == 10 +assert len(resources) == 20 +assert event_count == 26 +assert len(evaluations) == 2 +assert scores == [1.0, 1.0] +assert len(msgs) == 11 +``` + +Sad path: + +```python +assert by_slug["l_2"].status == FAILED +assert by_slug["l_3"].status == BLOCKED +assert by_slug["l_3"].started_at is None +assert len(msgs) == 7 +``` + +- [ ] **Step 2: Update imports only** + +Replace any private core imports with: + +```python +from tests.e2e._read_contracts import require_run_snapshot +from ergon_core.test_support.smoke_fixtures.smoke_base.constants import EXPECTED_SUBTASK_SLUGS +``` + +And, where direct DB access is still needed: + +```python +from ergon_core.test_support.e2e_read_helpers import ... +``` + +- [ ] **Step 3: Keep dashboard assertions aligned** + +Playwright specs should assert visible behavior: + +```text +- run status is completed/failed as appropriate +- all expected task nodes appear +- failed l_2 and blocked l_3 are visible on sad path +- resource/evaluation panels render when expected +``` + +Do not assert private API response shapes unless the dashboard API marks them public/stable. + +## Task 8: Run The Non-E2E Verification Gate + +**Files:** +- No code changes unless tests fail. + +- [ ] **Step 1: Run focused unit/integration tests** + +Run: + +```bash +uv run pytest \ + tests/unit/registry/test_react_factories.py \ + tests/unit/registry/test_builtin_pairings.py \ + tests/unit/cli/test_experiment_cli.py \ + tests/unit/cli/test_benchmark_setup.py \ + tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py \ + tests/unit/architecture/test_public_api_boundaries.py \ + tests/integration/smokes/test_smoke_harness.py \ + -q +``` + +Expected: pass or environment-gated integration skip. Any import failure from `tests/e2e` is a blocker. + +- [ ] **Step 2: Run e2e collection without executing live stack** + +Run: + +```bash +uv run pytest tests/e2e --collect-only -q +``` + +Expected: collection succeeds. This catches stale import paths without needing the stack. + +- [ ] **Step 3: Run lint diagnostics on touched test/docs paths** + +Use IDE lints for: + +```text +tests/e2e/ +tests/unit/registry/ +tests/unit/cli/ +tests/unit/smoke_base/ +docs/superpowers/plans/ +``` + +Expected: no new code-specific diagnostics. Environment import-resolution warnings are non-blocking only if pytest confirms imports. + +## Task 9: Full E2E Execution Gate + +**Files:** +- No code changes unless runtime evidence fails. + +- [ ] **Step 1: Verify stack env** + +Required environment: + +```text +ENABLE_TEST_HARNESS=1 +ENABLE_SMOKE_FIXTURES=1 +ERGON_STARTUP_PLUGINS=ergon_core.test_support.smoke_fixtures:register_smoke_fixtures +ERGON_API_BASE_URL=http://127.0.0.1:9000 +TEST_HARNESS_SECRET= +E2B_API_KEY= +``` + +- [ ] **Step 2: Run one smoke leg first** + +Run: + +```bash +uv run pytest tests/e2e/test_minif2f_smoke.py -q -s +``` + +Expected: + +- one happy run reaches `completed` +- one sad run reaches `failed` +- all hard assertions pass +- Playwright spec completes or captures failure screenshots + +- [ ] **Step 3: Run all smoke legs** + +Run: + +```bash +uv run pytest tests/e2e -q -s +``` + +Expected: + +- ResearchRubrics, MiniF2F, and SWE-Bench each submit happy/sad cohorts +- happy runs pass graph/resource/turn/evaluation/dashboard assertions +- sad runs pass blocked/failure/partial-artifact assertions + +## Task 10: Review And Handoff To Real-LLM Canaries + +**Files:** +- Modify only if review finds issues. + +- [ ] **Step 1: Request code review** + +Send reviewer scope: + +```text +Review built-ins, CLI, and e2e refactor completion. +Check that: +- no benchmark profiles/default pairings remain +- CLI requires explicit worker/model/evaluator/sandbox/extras +- e2e uses HTTP/test-support/read-model boundaries +- runtime behavior assertions remain hard +- no private core repository imports remain in e2e tests +``` + +- [ ] **Step 2: Fix Critical and Important review findings** + +Follow review feedback with tests for each fix. + +- [ ] **Step 3: Decide real-LLM canary timing** + +Only after e2e smoke is green, run or schedule: + +```bash +ERGON_REAL_LLM=1 uv run pytest tests/real_llm -q -s +``` + +If real-LLM tests still use stale CLI paths, update them to the same explicit runtime choice contract before running. + +## Completion Criteria + +- `tests/e2e --collect-only` succeeds without private core import failures. +- `tests/unit/architecture/test_public_api_boundaries.py` confirms e2e tests do not import private core repository/runtime internals. +- `tests/unit/registry/test_builtin_pairings.py` covers all documented production benchmark pairings. +- CLI parser tests prove explicit arguments are required. +- `/api/test/write/cohort` accepts explicit sandbox/extras and uses the same define/run facade path. +- Full e2e smoke suite preserves existing behavior assertions: + - 12 tasks, 10 leaves, 20 resources, 26 turns, 2 root evaluations on happy path + - `l_2` failed, `l_3` blocked, 7 completion messages on sad path +- Code review has no unresolved Critical or Important findings. + diff --git a/docs/superpowers/plans/2026-04-29-persistent-component-catalog-and-test-layout.md b/docs/superpowers/plans/2026-04-29-persistent-component-catalog-and-test-layout.md new file mode 100644 index 00000000..a72f7a5c --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-persistent-component-catalog-and-test-layout.md @@ -0,0 +1,1784 @@ +# Persistent Component Catalog And Test Layout Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make component registration understandable across processes by splitting tests by package ownership, persisting component slug-to-import references in Postgres, and deleting test/fixture env-var switches. + +**Architecture:** First reorganize tests so package boundaries are visible and cross-process E2E stays black-box. Then add a trusted `component_catalog` table in `ergon_core` that stores component kind, slug, module, qualname, and metadata. Finally, update the Pydantic registry to publish/load catalog rows, make runtime jobs resolve components through the catalog-backed registry, and remove `ENABLE_TEST_HARNESS`, `TEST_HARNESS_SECRET`, `ERGON_STARTUP_PLUGINS`, `ENABLE_SMOKE_FIXTURES`, and `ERGON_SKIP_INFRA_CHECK`. + +**Tech Stack:** Python 3.13, SQLModel, Alembic, Pydantic v2, pytest, FastAPI, argparse CLI, existing uv/pnpm scripts. + +--- + +## Service Design Constraint + +Use one catalog boundary: `ComponentCatalogService`. Do not implement both a service and repository for the catalog. The service owns the contract for publishing refs, requiring refs, and loading import refs; keep the API small so it does not become a second registry. + +## Mental Model + +The final system should be explainable as: + +1. Packages define components in Python code. +2. Packages publish component references into Postgres as trusted catalog rows. +3. Experiment definitions store stable slugs. +4. API/Inngest/CLI resolve slugs through the shared catalog, import the Python reference, and instantiate the component. +5. Tests are package-owned; only black-box E2E crosses process boundaries. + +The Pydantic registry remains useful as an authoring and publishing helper, but runtime resolution should read from Postgres every time. These lookups are not hot enough to justify an in-memory process-local cache, and always reading the catalog keeps cross-process behavior easier to reason about. + +## ID Model + +Use one worker-facing task identity: + +```python +Task.task_id == RunGraphNode.id +``` + +`RunGraphNode.id` is the runtime task id. It exists for every executable task in a run, including dynamically spawned subtasks. This is the only task id worker authors should see. + +Use explicit names for internal/template identity: + +```python +definition_id # ExperimentDefinition.id, the static experiment template +node_id # RunGraphNode.id, the runtime task identity +execution_id # RunTaskExecution.id, one attempt to execute a node +``` + +Do not pass `definition_task_id` through public `Task` or runtime event/job payloads. Keep it only as an optional persisted relationship on rows such as `RunGraphNode` / `RunTaskExecution` when the application layer needs static-template joins. If runtime needs definition data, resolve it from `node_id` through the persisted graph/run links (`RunGraphNode.run_id` -> `RunRecord.workflow_definition_id` -> `ExperimentDefinition`) or use the already available run/definition context in the application layer. + +## File Structure + +- Create package-owned test roots: + - `ergon_core/tests/` + - `ergon_builtins/tests/` + - `ergon_cli/tests/` + - optionally `ergon_infra/tests/` +- Keep cross-package black-box tests at: + - `tests/e2e/` + - `tests/real_llm/` + - `tests/fixtures/` only for fixtures intentionally shared by black-box tests. +- Create component catalog files: + - `ergon_core/ergon_core/core/persistence/components/models.py` + - `ergon_core/ergon_core/core/application/components/catalog.py` + - `ergon_core/migrations/versions/_add_component_catalog.py` +- Modify registry/bootstrap files: + - `ergon_core/ergon_core/api/benchmark/task.py` + - `ergon_core/ergon_core/api/worker/context.py` + - `ergon_core/ergon_core/api/worker/worker.py` + - `ergon_core/ergon_core/api/worker/__init__.py` + - `ergon_core/ergon_core/api/registry.py` + - `ergon_builtins/ergon_builtins/registry.py` + - `ergon_builtins/ergon_builtins/registry_core.py` + - `ergon_builtins/ergon_builtins/registry_data.py` + - `tests/fixtures/smoke_components/__init__.py` +- Modify runtime resolution files: + - `ergon_core/ergon_core/core/application/events/task_events.py` + - `ergon_core/ergon_core/core/application/jobs/models.py` + - `ergon_core/ergon_core/core/application/jobs/worker_execute.py` + - `ergon_core/ergon_core/core/application/jobs/execute_task.py` + - `ergon_core/ergon_core/core/application/workflows/orchestration.py` + - `ergon_core/ergon_core/core/application/jobs/evaluate_task_run.py` + - `ergon_core/ergon_core/core/application/jobs/sandbox_setup.py` + - `ergon_core/ergon_core/core/application/jobs/persist_outputs.py` + - `ergon_core/ergon_core/core/application/experiments/service.py` + - `ergon_core/ergon_core/core/application/experiments/launch.py` + - `ergon_core/ergon_core/core/application/workflows/service.py` + - `ergon_core/ergon_core/core/application/tasks/management.py` + - `ergon_core/ergon_core/core/domain/experiments/worker_spec.py` +- Modify harness/env-var files: + - `ergon_core/ergon_core/core/shared/settings.py` + - `ergon_core/ergon_core/core/rest_api/app.py` + - `ergon_core/ergon_core/core/rest_api/test_harness.py` + - `docker-compose.yml` + - `.github/workflows/e2e-benchmarks.yml` + - `.github/workflows/ci-fast.yml` + - `package.json` + - `scripts/smoke_local_up.sh` + - `scripts/smoke_local_run.sh` + - `tests/e2e/conftest.py` + - `tests/integration/conftest.py` + - dashboard test harness clients/routes that reference `TEST_HARNESS_SECRET`. + +--- + +### Task 1: Create Package-Owned Test Layout Guardrails + +**Files:** +- Create: `tests/unit/architecture/test_package_test_layout.py` +- Modify later: `package.json` + +- [ ] **Step 1: Write architecture test for target test layout** + +Create `tests/unit/architecture/test_package_test_layout.py`: + +```python +from pathlib import Path + + +def test_package_owned_test_roots_exist() -> None: + assert Path("ergon_core/tests").is_dir() + assert Path("ergon_builtins/tests").is_dir() + assert Path("ergon_cli/tests").is_dir() + + +def test_root_tests_are_black_box_or_shared_only() -> None: + allowed = { + "__init__.py", + "__pycache__", + "conftest.py", + "e2e", + "fixtures", + "integration", + "real_llm", + } + root_entries = {path.name for path in Path("tests").iterdir()} + assert root_entries <= allowed +``` + +- [ ] **Step 2: Run the architecture test and verify it fails** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_package_test_layout.py -q +``` + +Expected: FAIL because package-owned test roots do not exist and `tests/unit` still contains package-owned tests. + +- [ ] **Step 3: Create package-owned test directories** + +Create: + +```text +ergon_core/tests/unit/ +ergon_core/tests/integration/ +ergon_builtins/tests/unit/ +ergon_builtins/tests/integration/ +ergon_cli/tests/unit/ +ergon_cli/tests/integration/ +``` + +Add empty `__init__.py` files only if import/package semantics require them. Prefer no `__init__.py` for pytest discovery unless an existing pattern depends on package imports. + +- [ ] **Step 4: Update `package.json` scripts to include both old and new roots** + +Modify backend test scripts temporarily so moved tests can be discovered while migration is incremental: + +```json +"test:be:unit": "uv run pytest ergon_core/tests/unit ergon_builtins/tests/unit ergon_cli/tests/unit tests/unit -q -n auto --durations=20", +"test:be:coverage": "uv run pytest ergon_core/tests/unit ergon_builtins/tests/unit ergon_cli/tests/unit tests/unit tests/integration --cov=ergon_core --cov=ergon_builtins --cov-report=term-missing --cov-report=xml:coverage.xml" +``` + +- [ ] **Step 5: Run package layout test** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_package_test_layout.py -q +``` + +Expected: still FAIL until tests are moved in Tasks 2-4. + +--- + +### Task 2: Move Core-Owned Unit Tests To `ergon_core/tests` + +**Files:** +- Move tests from `tests/unit/api`, `tests/unit/runtime`, `tests/unit/sandbox`, selected `tests/unit/architecture`, selected `tests/unit/state`, and core app tests into `ergon_core/tests/unit`. +- Modify imports only where they reference moved fixture paths. + +- [ ] **Step 1: Move clearly core-owned directories** + +Move: + +```text +tests/unit/api/ -> ergon_core/tests/unit/api/ +tests/unit/runtime/ -> ergon_core/tests/unit/runtime/ +tests/unit/sandbox/ -> ergon_core/tests/unit/sandbox/ +tests/unit/persistence/ -> ergon_core/tests/unit/persistence/ +tests/unit/dashboard/ -> ergon_core/tests/unit/dashboard/ +``` + +Move standalone core app tests: + +```text +tests/unit/test_app_mounts_harness_conditionally.py -> ergon_core/tests/unit/test_app_mounts_harness_conditionally.py +tests/unit/test_dashboard_emitter_wiring.py -> ergon_core/tests/unit/test_dashboard_emitter_wiring.py +tests/unit/test_rollouts_di.py -> ergon_core/tests/unit/test_rollouts_di.py +tests/unit/test_test_harness.py -> ergon_core/tests/unit/test_test_harness.py +tests/unit/test_swebench_criterion_no_sandbox.py -> ergon_core/tests/unit/test_swebench_criterion_no_sandbox.py +``` + +- [ ] **Step 2: Move registry/core architecture tests** + +Move: + +```text +tests/unit/registry/ -> ergon_core/tests/unit/registry/ +tests/unit/architecture/test_api_runs_boundary.py -> ergon_core/tests/unit/architecture/test_api_runs_boundary.py +tests/unit/architecture/test_core_schema_sources.py -> ergon_core/tests/unit/architecture/test_core_schema_sources.py +tests/unit/architecture/test_model_field_descriptions.py -> ergon_core/tests/unit/architecture/test_model_field_descriptions.py +tests/unit/architecture/test_no_test_logic_in_core.py -> ergon_core/tests/unit/architecture/test_no_test_logic_in_core.py +tests/unit/architecture/test_persistence_boundaries.py -> ergon_core/tests/unit/architecture/test_persistence_boundaries.py +tests/unit/architecture/test_public_api_boundaries.py -> ergon_core/tests/unit/architecture/test_public_api_boundaries.py +tests/unit/architecture/test_public_api_target_structure.py -> ergon_core/tests/unit/architecture/test_public_api_target_structure.py +tests/unit/architecture/test_smoke_fixture_package_boundary.py -> ergon_core/tests/unit/architecture/test_smoke_fixture_package_boundary.py +``` + +Leave `tests/unit/architecture/test_package_test_layout.py` at root until the migration is complete because it governs the whole repo. + +- [ ] **Step 3: Run moved core tests** + +Run: + +```bash +uv run pytest ergon_core/tests/unit -q +``` + +Expected: PASS or failures that reveal imports still pointing at old `tests/unit/...` paths. + +- [ ] **Step 4: Fix import paths revealed by failures** + +For each failure, update imports to either: + +```python +from tests.fixtures... +``` + +for intentionally shared black-box fixtures, or local package test helpers under: + +```python +from ergon_core.tests... +``` + +Do not import `ergon_builtins` in core unit tests unless the test is explicitly an integration/boundary test that names that dependency. + +- [ ] **Step 5: Run old and new unit suites** + +Run: + +```bash +uv run pytest ergon_core/tests/unit tests/unit -q +``` + +Expected: PASS, with fewer tests left under `tests/unit`. + +--- + +### Task 3: Move Builtins-Owned Tests To `ergon_builtins/tests` + +**Files:** +- Move benchmark, worker, builtins state, smoke component tests that assert builtins behavior. + +- [ ] **Step 1: Move builtins benchmark/worker tests** + +Move: + +```text +tests/unit/benchmarks/ -> ergon_builtins/tests/unit/benchmarks/ +tests/unit/builtins/ -> ergon_builtins/tests/unit/builtins/ +tests/unit/workers/ -> ergon_builtins/tests/unit/workers/ +tests/unit/state/test_benchmark_contract.py -> ergon_builtins/tests/unit/state/test_benchmark_contract.py +tests/unit/state/test_gdpeval_benchmark.py -> ergon_builtins/tests/unit/state/test_gdpeval_benchmark.py +tests/unit/state/test_research_rubrics_benchmark.py -> ergon_builtins/tests/unit/state/test_research_rubrics_benchmark.py +tests/unit/state/test_research_rubrics_workers.py -> ergon_builtins/tests/unit/state/test_research_rubrics_workers.py +tests/unit/state/test_llm_judge_runtime_injection.py -> ergon_builtins/tests/unit/state/test_llm_judge_runtime_injection.py +tests/unit/state/test_criteria_do_not_spawn_sandboxes.py -> ergon_builtins/tests/unit/state/test_criteria_do_not_spawn_sandboxes.py +``` + +- [ ] **Step 2: Move smoke component unit tests** + +Move: + +```text +tests/unit/smoke_base/ -> ergon_builtins/tests/unit/smoke_base/ +``` + +Rationale: the fixture source remains at `tests/fixtures/smoke_components` because E2E consumes it as shared black-box fixture code, but unit tests for that fixture behavior should not live in root `tests/unit`. + +- [ ] **Step 3: Run builtins tests** + +Run: + +```bash +uv run pytest ergon_builtins/tests/unit -q +``` + +Expected: PASS or import failures from moved helper paths. + +- [ ] **Step 4: Fix moved builtins imports** + +Update any relative references from old root locations. Keep production imports from `ergon_builtins.*` unchanged. + +- [ ] **Step 5: Run package test subset** + +Run: + +```bash +uv run pytest ergon_builtins/tests/unit ergon_core/tests/unit tests/unit -q +``` + +Expected: PASS. + +--- + +### Task 4: Move CLI-Owned Tests To `ergon_cli/tests` + +**Files:** +- Move CLI unit tests and CLI-specific state tests. + +- [ ] **Step 1: Move CLI tests** + +Move: + +```text +tests/unit/cli/ -> ergon_cli/tests/unit/cli/ +tests/unit/state/test_onboard_profile.py -> ergon_cli/tests/unit/state/test_onboard_profile.py +tests/unit/state/test_env_writer.py -> ergon_cli/tests/unit/state/test_env_writer.py +tests/unit/state/test_openrouter_model_resolution.py -> ergon_cli/tests/unit/state/test_openrouter_model_resolution.py +tests/unit/state/test_subtask_lifecycle_toolkit.py -> ergon_cli/tests/unit/state/test_subtask_lifecycle_toolkit.py +tests/unit/state/test_workflow_cli_tool.py -> ergon_cli/tests/unit/state/test_workflow_cli_tool.py +``` + +- [ ] **Step 2: Run CLI tests** + +Run: + +```bash +uv run pytest ergon_cli/tests/unit -q +``` + +Expected: PASS or import failures that identify old paths. + +- [ ] **Step 3: Update `package.json` to remove old unit root once empty** + +After Tasks 2-4, if `tests/unit` contains only architecture migration tests or is empty, update scripts: + +```json +"test:be:unit": "uv run pytest ergon_core/tests/unit ergon_builtins/tests/unit ergon_cli/tests/unit -q -n auto --durations=20" +``` + +If a small root `tests/unit` remains for repo-wide architecture tests, include it explicitly: + +```json +"test:be:unit": "uv run pytest ergon_core/tests/unit ergon_builtins/tests/unit ergon_cli/tests/unit tests/unit -q -n auto --durations=20" +``` + +- [ ] **Step 4: Run package layout guardrail** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_package_test_layout.py -q +``` + +Expected: PASS. + +--- + +### Task 5: Add Component Catalog Persistence Model And Migration + +**Files:** +- Create: `ergon_core/ergon_core/core/persistence/components/models.py` +- Modify: `ergon_core/migrations/env.py` +- Create: `ergon_core/migrations/versions/_add_component_catalog.py` +- Test: `ergon_core/tests/unit/registry/test_component_catalog_model.py` + +- [ ] **Step 1: Write catalog model tests** + +Create `ergon_core/tests/unit/registry/test_component_catalog_model.py`: + +```python +import pytest + +from ergon_core.core.persistence.components.models import ComponentCatalogEntry + + +def test_component_catalog_entry_round_trips_metadata() -> None: + entry = ComponentCatalogEntry( + kind="worker", + slug="training-stub", + module="ergon_builtins.shared.workers.training_stub_worker", + qualname="TrainingStubWorker", + package="ergon-builtins", + metadata_json={"description": "offline worker"}, + ) + + assert entry.parsed_metadata() == {"description": "offline worker"} + + +def test_component_catalog_entry_rejects_invalid_kind() -> None: + with pytest.raises(ValueError, match="kind must be one of"): + ComponentCatalogEntry( + kind="not-a-kind", + slug="bad", + module="pkg.mod", + qualname="Thing", + ) +``` + +- [ ] **Step 2: Run catalog model tests and verify they fail** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/registry/test_component_catalog_model.py -q +``` + +Expected: FAIL because the model module does not exist. + +- [ ] **Step 3: Implement SQLModel catalog entry** + +Create `ergon_core/ergon_core/core/persistence/components/models.py`: + +```python +"""Persistent component catalog shared across CLI/API/Inngest processes.""" + +from datetime import datetime +from uuid import UUID, uuid4 + +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.shared.utils import utcnow as _utcnow +from pydantic import model_validator +from sqlalchemy import JSON, Column, DateTime, UniqueConstraint +from sqlmodel import Field, SQLModel + +TZDateTime = DateTime(timezone=True) +COMPONENT_KINDS = {"worker", "benchmark", "evaluator", "sandbox_manager"} + + +class ComponentCatalogEntry(SQLModel, table=True): + __tablename__ = "component_catalog" + __table_args__ = (UniqueConstraint("kind", "slug", name="uq_component_catalog_kind_slug"),) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + kind: str = Field(index=True) + slug: str = Field(index=True) + module: str + qualname: str + package: str | None = Field(default=None, index=True) + version: str | None = None + metadata_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) + created_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) + updated_at: datetime = Field(default_factory=_utcnow, sa_type=TZDateTime) + + def parsed_metadata(self) -> JsonObject: + return self.__class__._parse_metadata(self.metadata_json) + + @classmethod + def _parse_metadata(cls, data: dict) -> JsonObject: + if not isinstance(data, dict): + raise ValueError(f"metadata_json must be a dict, got {type(data).__name__}") + return data + + @model_validator(mode="after") + def _validate_entry(self) -> "ComponentCatalogEntry": + if self.kind not in COMPONENT_KINDS: + allowed = ", ".join(sorted(COMPONENT_KINDS)) + raise ValueError(f"kind must be one of: {allowed}") + if not self.slug: + raise ValueError("slug must be non-empty") + if not self.module: + raise ValueError("module must be non-empty") + if not self.qualname: + raise ValueError("qualname must be non-empty") + self.__class__._parse_metadata(self.metadata_json) + return self +``` + +- [ ] **Step 4: Import component models in Alembic env** + +Modify `ergon_core/migrations/env.py`: + +```python +import ergon_core.core.persistence.components.models +``` + +Add it beside the other persistence model imports. + +- [ ] **Step 5: Add Alembic migration** + +Create a migration file under `ergon_core/migrations/versions/` with a new revision id: + +```python +"""add component catalog + +Revision ID: d1e2f3a4b5c6 +Revises: c2d3e4f5a6b7 +Create Date: 2026-04-29 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "d1e2f3a4b5c6" +down_revision: str | None = "c2d3e4f5a6b7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "component_catalog", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("kind", sa.String(), nullable=False), + sa.Column("slug", sa.String(), nullable=False), + sa.Column("module", sa.String(), nullable=False), + sa.Column("qualname", sa.String(), nullable=False), + sa.Column("package", sa.String(), nullable=True), + sa.Column("version", sa.String(), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("kind", "slug", name="uq_component_catalog_kind_slug"), + ) + op.create_index("ix_component_catalog_kind", "component_catalog", ["kind"], unique=False) + op.create_index("ix_component_catalog_slug", "component_catalog", ["slug"], unique=False) + op.create_index("ix_component_catalog_package", "component_catalog", ["package"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_component_catalog_package", table_name="component_catalog") + op.drop_index("ix_component_catalog_slug", table_name="component_catalog") + op.drop_index("ix_component_catalog_kind", table_name="component_catalog") + op.drop_table("component_catalog") +``` + +Before choosing `down_revision`, inspect the current migration head with: + +```bash +uv run alembic -c ergon_core/alembic.ini heads +``` + +Use the actual head instead of the placeholder if different. + +- [ ] **Step 6: Run catalog model tests** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/registry/test_component_catalog_model.py -q +``` + +Expected: PASS. + +--- + +### Task 6: Add Component Catalog Service And Import Reference Loader + +**Files:** +- Create: `ergon_core/ergon_core/core/application/components/__init__.py` +- Create: `ergon_core/ergon_core/core/application/components/catalog.py` +- Test: `ergon_core/tests/unit/registry/test_component_catalog_service.py` + +- [ ] **Step 1: Write catalog service tests** + +Create `ergon_core/tests/unit/registry/test_component_catalog_service.py`: + +```python +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +from ergon_core.core.application.components.catalog import ( + ComponentCatalogService, + ComponentRef, + import_component_ref, +) +from ergon_core.core.persistence.components.models import ComponentCatalogEntry + + +def _session() -> Session: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def test_upsert_and_require_component_ref() -> None: + session = _session() + service = ComponentCatalogService() + + service.upsert( + session, + ComponentRef( + kind="worker", + slug="training-stub", + module="ergon_builtins.shared.workers.training_stub_worker", + qualname="TrainingStubWorker", + package="ergon-builtins", + metadata={"install_hint": "none"}, + ), + ) + session.commit() + + ref = service.require(session, kind="worker", slug="training-stub") + assert ref.module == "ergon_builtins.shared.workers.training_stub_worker" + assert ref.qualname == "TrainingStubWorker" + assert ref.metadata == {"install_hint": "none"} + + +def test_upsert_updates_existing_ref() -> None: + session = _session() + service = ComponentCatalogService() + + service.upsert(session, ComponentRef(kind="worker", slug="x", module="old", qualname="Thing")) + service.upsert(session, ComponentRef(kind="worker", slug="x", module="new", qualname="Other")) + session.commit() + + rows = session.query(ComponentCatalogEntry).all() + assert len(rows) == 1 + assert service.require(session, kind="worker", slug="x").module == "new" + + +def test_import_component_ref_imports_module_qualname() -> None: + ref = ComponentRef( + kind="worker", + slug="component-ref", + module="ergon_core.core.application.components.catalog", + qualname="ComponentRef", + ) + + assert import_component_ref(ref) is ComponentRef + + +def test_require_unknown_component_lists_kind_and_slug() -> None: + session = _session() + + with pytest.raises(ValueError, match="Unknown worker component slug 'missing'"): + ComponentCatalogService().require(session, kind="worker", slug="missing") +``` + +- [ ] **Step 2: Run catalog service tests and verify they fail** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/registry/test_component_catalog_service.py -q +``` + +Expected: FAIL because `ComponentCatalogService` does not exist. + +- [ ] **Step 3: Implement component catalog service** + +Create the package marker: + +```python +"""Component catalog application services.""" +``` + +Create `ergon_core/ergon_core/core/application/components/catalog.py`: + +```python +"""Application service for trusted component catalog references.""" + +from importlib import import_module +from typing import Any + +from ergon_core.core.persistence.components.models import ComponentCatalogEntry +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.shared.utils import utcnow +from pydantic import BaseModel, ConfigDict, Field +from sqlmodel import Session, select + + +class ComponentRef(BaseModel): + model_config = ConfigDict(frozen=True) + + kind: str + slug: str + module: str + qualname: str + package: str | None = None + version: str | None = None + metadata: JsonObject = Field(default_factory=dict) + + +class ComponentCatalogService: + def upsert(self, session: Session, ref: ComponentRef) -> ComponentCatalogEntry: + existing = session.exec( + select(ComponentCatalogEntry).where( + ComponentCatalogEntry.kind == ref.kind, + ComponentCatalogEntry.slug == ref.slug, + ) + ).one_or_none() + + row = existing or ComponentCatalogEntry( + kind=ref.kind, + slug=ref.slug, + module=ref.module, + qualname=ref.qualname, + ) + row.module = ref.module + row.qualname = ref.qualname + row.package = ref.package + row.version = ref.version + row.metadata_json = dict(ref.metadata) + row.updated_at = utcnow() + session.add(row) + return row + + def require(self, session: Session, *, kind: str, slug: str) -> ComponentRef: + row = session.exec( + select(ComponentCatalogEntry).where( + ComponentCatalogEntry.kind == kind, + ComponentCatalogEntry.slug == slug, + ) + ).one_or_none() + if row is None: + raise ValueError(f"Unknown {kind} component slug {slug!r}") + return _row_to_ref(row) + + def load_ref(self, ref: ComponentRef) -> Any: # slopcop: ignore[no-typing-any] + return import_component_ref(ref) + + +def import_component_ref(ref: ComponentRef) -> Any: # slopcop: ignore[no-typing-any] + target: Any = import_module(ref.module) # slopcop: ignore[no-typing-any] + for part in ref.qualname.split("."): + target = getattr(target, part) + return target + + +def _row_to_ref(row: ComponentCatalogEntry) -> ComponentRef: + return ComponentRef( + kind=row.kind, + slug=row.slug, + module=row.module, + qualname=row.qualname, + package=row.package, + version=row.version, + metadata=row.parsed_metadata(), + ) +``` + +- [ ] **Step 4: Run catalog service tests** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/registry/test_component_catalog_service.py -q +``` + +Expected: PASS. + +--- + +### Task 7: Move Execution Identity Out Of Worker Construction + +**Files:** +- Modify: `ergon_core/ergon_core/api/benchmark/task.py` +- Modify: `ergon_core/ergon_core/api/worker/context.py` +- Modify: `ergon_core/ergon_core/api/worker/worker.py` +- Modify: `ergon_core/ergon_core/core/application/events/task_events.py` +- Modify: `ergon_core/ergon_core/core/application/jobs/models.py` +- Modify: `ergon_core/ergon_core/core/application/workflows/orchestration.py` +- Modify: `ergon_core/ergon_core/core/application/jobs/execute_task.py` +- Modify worker subclasses/factories that still require `task_id` or `sandbox_id` +- Test: `ergon_core/tests/unit/api/test_worker_contract.py` + +- [ ] **Step 1: Write worker construction contract tests** + +Create `ergon_core/tests/unit/api/test_worker_contract.py`: + +```python +from collections.abc import AsyncGenerator +from uuid import uuid4 + +from ergon_core.api.benchmark import Task +from ergon_core.api.worker import Worker, WorkerContext, WorkerOutput +from ergon_core.api.worker.worker import WorkerStreamItem + + +class ContractSmokeWorker(Worker): + type_slug = "contract-smoke-worker" + + async def execute( + self, + task: Task, + *, + context: WorkerContext, + ) -> AsyncGenerator[WorkerStreamItem, None]: + yield WorkerOutput(output="ok", success=True) + + +def test_worker_constructor_has_only_authoring_configuration() -> None: + worker = ContractSmokeWorker(name="primary", model="stub:constant") + + assert isinstance(worker, ContractSmokeWorker) + assert worker.name == "primary" + assert worker.model == "stub:constant" + + +def test_task_carries_non_null_runtime_task_identity() -> None: + node_id = uuid4() + + task = Task( + task_id=node_id, + task_slug="root", + instance_key="default", + description="Run root task", + ) + + assert task.task_id == node_id +``` + +- [ ] **Step 2: Run worker contract tests and verify they fail** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/api/test_worker_contract.py -q +``` + +Expected: FAIL because `Task.task_id` does not exist yet and `Worker.__init__` still requires `task_id` and `sandbox_id`. + +- [ ] **Step 3: Add non-null task identity to `Task`** + +Modify `ergon_core/ergon_core/api/benchmark/task.py`: + +```python +from uuid import UUID + +class Task(BaseModel, Generic[PayloadT]): + task_id: UUID + task_slug: str + instance_key: str + description: str +``` + +`Task.task_id` is the worker-facing runtime task identity. It must always be `RunGraphNode.id`, not `ExperimentDefinitionTask.id`. Static definition tasks and dynamic subtasks both have a `RunGraphNode`, so worker authors get one non-null task id for every execution. + +Remove the old nullable event/request `task_id` from runtime payloads. Runtime events/jobs should carry `node_id` as the task identity: + +```python +node_id: UUID # RunGraphNode.id; runtime task identity +``` + +Then remove the nullable worker-facing `task_id` from `WorkerContext`. The worker-facing contract should be: + +```python +task.task_id # non-null RunGraphNode.id +context.sandbox_id # non-null sandbox identity +``` + +If helper tools need a sandbox/task key, pass `task.task_id` to those helpers explicitly when building them. Do not use `WorkerContext.task_id` as a second, nullable source of truth. + +- [ ] **Step 3b: Remove nullable task identity from runtime payloads** + +Remove internal event and job fields that currently use nullable `task_id` for `ExperimentDefinitionTask.id`: + +```python +class TaskReadyEvent(InngestEventContract): + run_id: UUID + definition_id: UUID + node_id: UUID +``` + +Apply the same shape to: + +- `TaskStartedEvent` +- `TaskCompletedEvent` +- `TaskFailedEvent` +- `PrepareTaskExecutionCommand` +- `WorkerExecuteRequest` +- `EvaluateTaskRunRequest` + +Keep `PreparedTaskExecution.node_id` as the canonical runtime task identity. Keep `RunGraphNode.definition_task_id` and `RunTaskExecution.definition_task_id` only as persisted relationships for static-template joins. If a service needs the static definition task row, it should load `RunGraphNode` by `node_id` and follow `RunGraphNode.definition_task_id`; do not carry that id through event payloads or public `Task`. + +- [ ] **Step 4: Simplify `Worker.__init__`** + +Modify `ergon_core/ergon_core/api/worker/worker.py`: + +```python +def __init__( + self, + *, + name: str, + model: str | None, + metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] +) -> None: + self.name = name + self.model = model + self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] +``` + +Do not keep `self.task_id` or `self.sandbox_id` on `Worker`. Workers should use `task.task_id` and `context.sandbox_id` inside `execute(...)`. + +- [ ] **Step 5: Refactor builtin worker factories into Worker subclasses** + +Replace factory functions such as `minif2f_react(...)` and `swebench_react(...)` with importable `Worker` subclasses. Those classes should build sandbox-bound tools inside `execute(...)`, using the runtime objects they already receive: + +```python +async def execute(self, task: Task, *, context: WorkerContext) -> AsyncGenerator[WorkerStreamItem, None]: + sandbox = MiniF2FSandboxManager().reconnect(context.sandbox_id) + toolkit = MiniF2FToolkit(...) + delegate = ReActWorker( + name=self.name, + model=self.model, + tools=list(toolkit.get_tools()), + system_prompt=MINIF2F_SYSTEM_PROMPT, + max_iterations=30, + ) + async for item in delegate.execute(task, context=context): + yield item +``` + +If a sandbox manager currently only looks up sandboxes by definition task id, add a public lookup/reconnect path by `sandbox_id`. Do not force worker construction to know about sandbox registry keys. + +- [ ] **Step 6: Run worker contract tests** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/api/test_worker_contract.py -q +``` + +Expected: PASS. + +--- + +### Task 8: Update Pydantic Registry To Produce And Publish Component Refs + +**Files:** +- Modify: `ergon_core/ergon_core/api/registry.py` +- Test: `ergon_core/tests/unit/registry/test_component_registry.py` + +- [ ] **Step 1: Add tests for ref generation and deregistration** + +Extend `ergon_core/tests/unit/registry/test_component_registry.py`: + +```python +def test_registry_records_import_refs_for_registered_components() -> None: + registry = ComponentRegistry(catalog_service=ComponentCatalogService()) + + registry.register_worker(ExampleWorker.type_slug, ExampleWorker) + ref = registry.component_refs[("worker", "example-worker")] + + assert ref.kind == "worker" + assert ref.slug == "example-worker" + assert ref.module == __name__ + assert ref.qualname == "ExampleWorker" + + +def test_registry_deregister_removes_component_and_ref() -> None: + registry = ComponentRegistry(catalog_service=ComponentCatalogService()) + registry.register_worker("example-worker", ExampleWorker) + + registry.deregister("worker", "example-worker") + + assert "example-worker" not in registry.workers + assert ("worker", "example-worker") not in registry.component_refs +``` + +- [ ] **Step 2: Run registry tests and verify they fail** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/registry/test_component_registry.py -q +``` + +Expected: FAIL because `component_refs` and `deregister` do not exist. + +- [ ] **Step 3: Add `ComponentRef` tracking to `ComponentRegistry`** + +Modify `ergon_core/ergon_core/api/registry.py`: + +```python +from ergon_core.core.application.components.catalog import ComponentCatalogService, ComponentRef +from sqlmodel import Session +``` + +Add field: + +```python +catalog_service: ComponentCatalogService +component_refs: dict[tuple[str, str], ComponentRef] = Field(default_factory=dict) +``` + +Update register methods to call a private helper after `_register`: + +```python +self._remember_ref("worker", slug, worker_cls) +``` + +Implement: + +```python +def deregister(self, kind: str, slug: str) -> None: + mapping = self._mapping_for(kind) + mapping.pop(slug, None) + self.component_refs.pop((kind, slug), None) + +def publish(self, session: Session) -> None: + for ref in self.component_refs.values(): + self.catalog_service.upsert(session, ref) + +def _remember_ref(self, kind: str, slug: str, value: object) -> None: + self.component_refs[(kind, slug)] = ComponentRef( + kind=kind, + slug=slug, + module=value.__module__, + qualname=value.__qualname__, + ) +``` + +For worker classes, `__qualname__` is sufficient if the class is module-level. If a value lacks `__module__` or `__qualname__`, raise `ValueError` with a clear message. Do not preserve the old `WorkerFactory` public alias; workers should be registered as importable `Worker` subclasses and constructed by the catalog with only authoring configuration (`name`, `model`, metadata). + +Construct the global authoring registry with an explicit service dependency: + +```python +registry = ComponentRegistry(catalog_service=ComponentCatalogService()) +``` + +Do not use nullable service parameters or ad hoc fallback construction such as `service or ComponentCatalogService()`. Tests that need isolation should pass their own `ComponentCatalogService()` when constructing a fresh `ComponentRegistry`. + +- [ ] **Step 4: Run registry tests** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/registry/test_component_registry.py -q +``` + +Expected: PASS. + +--- + +### Task 9: Register Builtins And Smoke Components Into The Catalog + +**Files:** +- Modify: `ergon_builtins/ergon_builtins/registry.py` +- Modify: `tests/fixtures/smoke_components/__init__.py` +- Test: `ergon_builtins/tests/unit/registry/test_builtin_pairings.py` or moved equivalent. + +- [ ] **Step 1: Add tests that builtins can publish refs into a DB session** + +Create or extend builtins registry tests: + +```python +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +from ergon_core.api.registry import ComponentRegistry +from ergon_core.core.application.components.catalog import ComponentCatalogService + + +def _session() -> Session: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def test_register_builtins_can_publish_component_refs() -> None: + from ergon_builtins.registry import register_builtins + + service = ComponentCatalogService() + registry = ComponentRegistry(catalog_service=service) + register_builtins(registry) + session = _session() + + registry.publish(session) + session.commit() + + ref = service.require(session, kind="worker", slug="training-stub") + assert ref.module.endswith("training_stub_worker") + assert ref.qualname == "TrainingStubWorker" +``` + +- [ ] **Step 2: Run publishing test and verify it fails if refs are incomplete** + +Run: + +```bash +uv run pytest ergon_builtins/tests/unit/registry -q +``` + +Expected: PASS if Task 8 is complete; otherwise FAIL on missing refs. + +- [ ] **Step 3: Keep publishing explicit and outside registration functions** + +Keep registration functions focused on filling the in-process authoring registry: + +```python +def register_builtins(target: ComponentRegistry = registry) -> None: + register_core_builtins(target) + _register_local_model_builtins() + _register_data_builtins(target) +``` + +Do not make builtins import DB/session code. Keep publishing as an explicit caller responsibility: + +```python +register_builtins(registry) +with get_session() as session: + registry.publish(session) + session.commit() +``` + +This keeps builtins package independent of persistence. + +- [ ] **Step 4: Run builtins registry tests** + +Run: + +```bash +uv run pytest ergon_builtins/tests/unit/registry -q +``` + +Expected: PASS. + +- [ ] **Step 5: Remove legacy builtins registry dict snapshots** + +After publishing tests pass, delete legacy dict snapshot exports from `ergon_builtins/ergon_builtins/registry.py`. The top-level builtins registry module should expose registration functions and install hints only, not old process-local maps. + +Remove exports named: + +```python +BENCHMARKS +WORKERS +EVALUATORS +SANDBOX_MANAGERS +MODEL_BACKENDS +``` + +Keep sub-registry implementation details in `registry_core.py` and `registry_data.py` only as inputs to `register_core_builtins()` and `register_data_builtins()`. Update tests/callers that imported top-level dict snapshots to use either `ComponentRegistry` in authoring tests or `ComponentCatalogService` in runtime/catalog tests. + +- [ ] **Step 6: Convert worker factory functions to Worker subclasses** + +Before publishing worker refs into the catalog, ensure every registered worker slug points at an importable `Worker` subclass. If any existing builtins are module-level factory functions that return workers, replace them with small `Worker` subclasses or move their construction logic into the subclass initializer. + +This keeps the public mental model simple: + +```python +register_worker("training-stub", TrainingStubWorker) +worker = catalog.build_worker(session, slug="training-stub", name="primary", model="stub:constant") +``` + +There should be no public `Callable[..., Worker]` / `WorkerFactory` API after this migration. + +--- + +### Task 10: Add Catalog-Only Runtime Loading + +**Files:** +- Modify: `ergon_core/ergon_core/core/application/components/catalog.py` +- Modify runtime files listed in file structure. +- Test: core runtime registry tests. + +- [ ] **Step 1: Add test for catalog-backed runtime loading** + +Create `ergon_core/tests/unit/registry/test_catalog_backed_registry_resolution.py`: + +```python +from collections.abc import AsyncGenerator +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +from ergon_core.api.benchmark import Task +from ergon_core.api.worker import Worker, WorkerContext, WorkerOutput +from ergon_core.api.worker.worker import WorkerStreamItem +from ergon_core.core.application.components.catalog import ComponentCatalogService, ComponentRef + + +class CatalogSmokeWorker(Worker): + type_slug = "catalog-smoke-worker" + + async def execute( + self, + task: Task, + *, + context: WorkerContext, + ) -> AsyncGenerator[WorkerStreamItem, None]: + yield WorkerOutput(output="ok", success=True) + + +def _session() -> Session: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def test_build_worker_imports_worker_class_without_local_registration() -> None: + session = _session() + service = ComponentCatalogService() + service.upsert( + session, + ComponentRef( + kind="worker", + slug=CatalogSmokeWorker.type_slug, + module=__name__, + qualname="CatalogSmokeWorker", + ), + ) + session.commit() + + loaded = service.build_worker( + session, + slug=CatalogSmokeWorker.type_slug, + name="primary", + model="stub:constant", + ) + + assert isinstance(loaded, CatalogSmokeWorker) + assert loaded.name == "primary" +``` + +This test proves the catalog imports the persisted worker class and returns a real `Worker` without requiring process-local registry state or execution-only constructor arguments. + +- [ ] **Step 2: Run test and verify it fails** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/registry/test_catalog_backed_registry_resolution.py -q +``` + +Expected: FAIL because `build_worker` does not exist yet. + +- [ ] **Step 3: Add catalog loading without registry caching** + +Do not extend `ComponentRegistry.require_*` into a cache-loading runtime API. Keep `ComponentRegistry` focused on in-process authoring, validation of explicitly registered objects, and publishing refs into the catalog. + +Add one generic loading helper to `ComponentCatalogService` for non-worker component types: + +```python +def load_ref(self, ref: ComponentRef) -> object: + return import_component_ref(ref) +``` + +Runtime code should call catalog resolution directly and not populate `registry.workers`, `registry.benchmarks`, `registry.evaluators`, or `registry.sandbox_managers`. + +- [ ] **Step 4: Add typed catalog loading helpers** + +Add typed helpers on `ComponentCatalogService` because they make runtime call sites easier to read. Workers should produce a real `Worker`, not a factory/constructor object. + +```python +def build_worker( + self, + session: Session, + *, + slug: str, + name: str, + model: str | None, +) -> Worker: + ref = self.require(session, kind="worker", slug=slug) + worker_cls = self.load_ref(ref) + if not isinstance(worker_cls, type) or not issubclass(worker_cls, Worker): + raise TypeError( + f"Worker component {slug!r} resolved to {worker_cls!r}, expected a Worker subclass" + ) + return worker_cls( + name=name, + model=model, + metadata=ref.metadata, + ) + +def resolve_benchmark(self, session: Session, slug: str) -> type[Benchmark]: + return self.load_ref(self.require(session, kind="benchmark", slug=slug)) + +def resolve_evaluator(self, session: Session, slug: str) -> type[Evaluator]: + return self.load_ref(self.require(session, kind="evaluator", slug=slug)) + +def resolve_sandbox_manager(self, session: Session, slug: str) -> type[BaseSandboxManager]: + return self.load_ref(self.require(session, kind="sandbox_manager", slug=slug)) +``` + +These helpers must still read from Postgres and import the component on each call; do not populate `registry.workers`, `registry.benchmarks`, `registry.evaluators`, or `registry.sandbox_managers`. + +- [ ] **Step 5: Run catalog-backed registry tests** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/registry -q +``` + +Expected: PASS. + +--- + +### Task 11: Publish Catalog Rows During CLI/API/Test Bootstrap + +**Files:** +- Modify: `ergon_cli/ergon_cli/main.py` +- Modify: `ergon_core/ergon_core/core/rest_api/app.py` +- Modify: test setup files. + +- [ ] **Step 1: Replace env-var plugin startup with explicit bootstrap helper** + +Create a function in a non-core module, for example `ergon_cli/ergon_cli/bootstrap.py`: + +```python +"""Process bootstrap for local CLI/API components.""" + +from ergon_builtins.registry import register_builtins +from ergon_core.api.registry import registry +from ergon_core.core.persistence.shared.db import get_session + + +def register_and_publish_builtins() -> None: + register_builtins(registry) + with get_session() as session: + registry.publish(session) + session.commit() +``` + +- [ ] **Step 2: Call bootstrap from CLI startup** + +Modify `ergon_cli/ergon_cli/main.py`: + +```python +from ergon_cli.bootstrap import register_and_publish_builtins +``` + +Call it before command handlers run. If commands like `doctor` should not require DB, skip publishing for those commands by calling it only in experiment/benchmark/eval/workflow handlers. + +- [ ] **Step 3: Add API startup bootstrap without env plugins** + +Do not import tests from core app. For local Docker, choose one explicit bootstrap: + +Option A, if `app.py` is local/dev-only: + +```python +from ergon_builtins.registry import register_builtins +from ergon_core.api.registry import registry + +register_builtins(registry) +with get_session() as session: + registry.publish(session) + session.commit() +``` + +Option B, if strict core independence is still desired: + +Create `ergon_cli/ergon_cli/api_app.py` or a top-level `ergon_app/local_api.py` that imports core `app`, registers/publishes builtins, registers/publishes smoke fixtures, and is the uvicorn target used by docker compose. + +Recommendation: use Option B to avoid recreating core-to-builtins coupling. + +- [ ] **Step 4: Add smoke publishing in test bootstrap** + +For E2E/local Docker, explicit Python bootstrap should call: + +```python +from tests.fixtures.smoke_components import register_smoke_components + +register_smoke_components(registry) +with get_session() as session: + registry.publish(session) + session.commit() +``` + +Host-side pytest can still call this for in-process tests, but E2E must publish inside the API/Inngest process or before the stack starts against the shared DB. + +- [ ] **Step 5: Run CLI/API bootstrap tests** + +Run: + +```bash +uv run pytest ergon_cli/tests/unit ergon_core/tests/unit/test_app_mounts_harness_conditionally.py -q +``` + +Expected: PASS after tests are updated for no `ENABLE_TEST_HARNESS`. + +--- + +### Task 12: Update Runtime Jobs To Resolve Through Catalog When Needed + +**Files:** +- Modify runtime files listed in file structure. +- Test: existing runtime job tests plus new catalog-backed tests. + +- [ ] **Step 1: Update worker execute job** + +In `worker_execute.py`, when resolving worker and benchmark: + +```python +with get_session() as session: + worker = catalog.build_worker( + session, + slug=payload.worker_type, + name=payload.assigned_worker_slug, + model=payload.model_target, + ) +``` + +Build the `Task` with the runtime graph node identity. Do not derive this from the nullable static definition task id: + +```python +if payload.node_id is None: + raise ContractViolationError("worker-execute requires node_id") + +task = Task( + task_id=payload.node_id, + task_slug=payload.task_slug, + instance_key=instance_key, + description=payload.task_description, + task_payload=task_payload or EmptyTaskPayload(), +) +``` + +Build `WorkerContext` without duplicating task identity: + +```python +worker_context = WorkerContext( + run_id=payload.run_id, + definition_id=payload.definition_id, + execution_id=payload.execution_id, + sandbox_id=payload.sandbox_id, +) +``` + +`WorkerExecuteRequest` should carry only the runtime task id: + +```python +node_id: UUID # runtime task id, always present +``` + +If worker execution needs static task payload or instance data, resolve it from the persisted graph node: + +```python +node = session.get(RunGraphNode, payload.node_id) +if node is None: + raise ContractViolationError(f"RunGraphNode {payload.node_id} not found") + +if node.definition_task_id is not None: + task_row, instance_row = DefinitionRepository().task_with_instance( + session, + node.definition_task_id, + ) + task_payload = task_row.task_payload_as(benchmark_cls.task_payload_model) + instance_key = instance_row.instance_key +else: + task_payload = None + instance_key = str(payload.node_id) +``` + +Avoid opening duplicate sessions if the function already opens a session for task rows. Reuse the existing session where practical. + +- [ ] **Step 2: Update evaluate task job** + +Use: + +```python +evaluator_cls = catalog.resolve_evaluator(session, evaluator_type) +benchmark_cls = catalog.resolve_benchmark(session, benchmark_type) +manager_cls = catalog.resolve_sandbox_manager(session, benchmark_type) +``` + +Do not keep the previous `DefaultSandboxManager` fallback for known benchmark/sandbox slugs. If a persisted benchmark or sandbox slug has no catalog entry, raise immediately; that means definition-time validation or catalog publishing failed. + +- [ ] **Step 3: Update sandbox setup and persist outputs** + +Use catalog resolution where a sandbox slug is explicit. Do not fall back to `DefaultSandboxManager` for unknown explicit slugs. The purpose of definition-time validation is to prevent unknown slugs from being persisted; if one still reaches runtime, fail loudly with the missing slug and registry/catalog context. + +```python +manager_cls = catalog.resolve_sandbox_manager(session, slug) +``` + +- [ ] **Step 4: Update experiment service and launch** + +Resolve benchmark/evaluator via catalog-backed `require_*` using the DB session already used in the service. + +- [ ] **Step 5: Update workflow/task validation** + +Replace `slug in registry.workers` checks with catalog-backed existence checks: + +```python +catalog.require(session, kind="worker", slug=slug) +``` + +This is the point where cross-process correctness improves: validation no longer depends on the current process having imported builtins first. + +- [ ] **Step 6: Run runtime tests** + +Run: + +```bash +uv run pytest ergon_core/tests/unit/runtime ergon_core/tests/unit/registry -q +``` + +Expected: PASS. + +--- + +### Task 13: Delete `ERGON_STARTUP_PLUGINS` And `ENABLE_SMOKE_FIXTURES` + +**Files:** +- Modify: `ergon_core/ergon_core/core/shared/settings.py` +- Modify: `ergon_core/ergon_core/core/rest_api/app.py` +- Modify: `ergon_cli/ergon_cli/composition/__init__.py` +- Modify: `docker-compose.yml`, `.github/workflows/e2e-benchmarks.yml`, scripts/docs/tests. + +- [ ] **Step 1: Add grep-based env-var deletion test** + +Create `tests/unit/architecture/test_retired_env_vars.py`: + +```python +from pathlib import Path + + +RETIRED = { + "ERGON_STARTUP_PLUGINS", + "ENABLE_SMOKE_FIXTURES", +} + + +def test_retired_plugin_and_smoke_env_vars_are_not_used_in_code() -> None: + offenders: list[str] = [] + roots = [Path("ergon_core"), Path("ergon_cli"), Path("ergon_builtins"), Path("tests"), Path("scripts")] + for root in roots: + for path in root.rglob("*"): + if path.is_file() and path.suffix in {".py", ".sh", ".ts", ".tsx", ".yml", ".yaml", ".json"}: + text = path.read_text(errors="ignore") + if any(name in text for name in RETIRED): + offenders.append(str(path)) + assert offenders == [] +``` + +- [ ] **Step 2: Run env-var deletion test and verify it fails** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_retired_env_vars.py -q +``` + +Expected: FAIL listing current usage. + +- [ ] **Step 3: Remove startup plugin settings and loader** + +Delete from `Settings`: + +```python +startup_plugin_specs +startup_plugins +``` + +Delete `_run_startup_plugins` from `app.py`. + +- [ ] **Step 4: Remove `ENABLE_SMOKE_FIXTURES` fallback** + +In `ergon_cli/ergon_cli/composition/__init__.py`, delete: + +```python +os.environ.get("ENABLE_SMOKE_FIXTURES", ...) +``` + +Smoke registration should happen through explicit test/bootstrap code, not inside generic CLI composition. + +- [ ] **Step 5: Remove env vars from compose/workflows/scripts** + +Delete `ERGON_STARTUP_PLUGINS` and `ENABLE_SMOKE_FIXTURES` from: + +```text +docker-compose.yml +.github/workflows/e2e-benchmarks.yml +scripts/smoke_local_up.sh +tests/real_llm/benchmarks/test_smoke_stub.py +``` + +- [ ] **Step 6: Run deletion test** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_retired_env_vars.py -q +``` + +Expected: PASS. + +--- + +### Task 14: Delete `ENABLE_TEST_HARNESS` And `TEST_HARNESS_SECRET` + +**Files:** +- Modify: `ergon_core/ergon_core/core/shared/settings.py` +- Modify: `ergon_core/ergon_core/core/rest_api/app.py` +- Modify: `ergon_core/ergon_core/core/rest_api/test_harness.py` +- Modify dashboard test clients/routes referencing `TEST_HARNESS_SECRET`. +- Modify compose/workflows/package scripts/docs. + +- [ ] **Step 1: Extend retired env-var test** + +Add to `RETIRED`: + +```python +"ENABLE_TEST_HARNESS", +"TEST_HARNESS_SECRET", +``` + +Run: + +```bash +uv run pytest tests/unit/architecture/test_retired_env_vars.py -q +``` + +Expected: FAIL listing all remaining uses. + +- [ ] **Step 2: Always mount test harness under a danger-prefixed route** + +Change test harness router: + +```python +router = APIRouter(prefix="/api/__danger__/test-harness", tags=["danger-test-harness"]) +``` + +Update all clients from `/api/test/...` to `/api/__danger__/test-harness/...`. + +- [ ] **Step 3: Remove secret requirement from write endpoints** + +Delete `_require_secret` from `test_harness.py`. + +Remove `x_test_secret` parameters and `_require_secret(x_test_secret)` calls from: + +```python +seed_run +reset_test_rows +``` + +Decide whether `submit_cohort` should remain write-but-unguarded; with the danger-prefixed route, it should also be under the same unauthenticated local harness policy. + +- [ ] **Step 4: Remove conditional mount** + +In `app.py`, replace: + +```python +if settings.enable_test_harness: + app.include_router(_test_harness_router) +``` + +with: + +```python +app.include_router(_test_harness_router) +``` + +Delete `enable_test_harness` from `Settings`. + +- [ ] **Step 5: Update dashboard and Python clients** + +Update: + +```text +ergon-dashboard/tests/helpers/backendHarnessClient.ts +ergon-dashboard/src/app/api/test/dashboard/seed/route.ts +ergon-dashboard/src/lib/config.ts +tests/e2e/_asserts.py +tests/e2e/test_*_smoke.py +tests/integration/smokes/test_smoke_harness.py +package.json +scripts/smoke_local_run.sh +``` + +Remove `X-Test-Secret` headers and env lookups. Update URL paths to danger-prefixed harness routes. + +- [ ] **Step 6: Update tests for always-mounted harness** + +Replace `test_app_mounts_harness_conditionally.py` with a test named: + +```python +def test_app_mounts_danger_test_harness_routes() -> None: + routes = {route.path for route in app.routes} + assert "/api/__danger__/test-harness/read/run/{run_id}/state" in routes +``` + +- [ ] **Step 7: Run retired env-var test** + +Run: + +```bash +uv run pytest tests/unit/architecture/test_retired_env_vars.py -q +``` + +Expected: PASS. + +--- + +### Task 15: Verification + +**Files:** +- No planned source files beyond fixes revealed by tests. + +- [ ] **Step 1: Verify retired env vars are gone** + +Run: + +```bash +rg "ENABLE_TEST_HARNESS|TEST_HARNESS_SECRET|ERGON_STARTUP_PLUGINS|ENABLE_SMOKE_FIXTURES|ERGON_SKIP_INFRA_CHECK" ergon_core ergon_builtins ergon_cli tests scripts docker-compose.yml .github package.json ergon-dashboard -n +``` + +Expected: no matches, except historical docs if the team chooses not to update old planning documents. The architecture test should search code/config, not historical plans. + +- [ ] **Step 2: Verify component catalog migration imports** + +Run: + +```bash +uv run alembic -c ergon_core/alembic.ini upgrade head +``` + +Expected: migration succeeds on a local/dev DB. + +- [ ] **Step 3: Run package-owned unit tests** + +Run: + +```bash +uv run pytest ergon_core/tests/unit ergon_builtins/tests/unit ergon_cli/tests/unit tests/unit -q +``` + +Expected: PASS. + +- [ ] **Step 4: Run backend unit script** + +Run: + +```bash +pnpm run test:be:unit +``` + +Expected: PASS. + +- [ ] **Step 5: Run E2E collection** + +Run: + +```bash +uv run pytest tests/e2e --collect-only -q +``` + +Expected: PASS. + +- [ ] **Step 6: Run lint on changed Python paths** + +Run: + +```bash +uv run ruff check ergon_core ergon_builtins ergon_cli tests scripts +``` + +Expected: PASS. + +--- + +## Self-Review + +- Spec coverage: The plan covers package-owned test layout, PG component catalog schema, catalog service, registry publishing/loading, runtime refactor, and deletion of all five env vars named in the discussion. +- Placeholder scan: The plan contains no placeholder instructions. The migration revision id must be chosen from the actual Alembic head during execution, and the plan explicitly instructs how to do that. +- Type consistency: The same names are used throughout: `ComponentCatalogEntry`, `ComponentCatalogService`, `ComponentRef`, `component_catalog`, `registry.publish`, and catalog-backed `require_*` methods. From 323a1b298b9f562b6cdeda135e43b9af1a82b316 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:44 +0100 Subject: [PATCH 43/66] refactor: split public benchmark API package Made-with: Cursor --- .../ergon_core/api/benchmark/__init__.py | 7 ++++ .../api/{ => benchmark}/benchmark.py | 32 ++++--------------- .../ergon_core/api/benchmark/requirements.py | 11 +++++++ .../api/{task_types.py => benchmark/task.py} | 8 ++--- ergon_core/ergon_core/api/benchmark_deps.py | 19 ----------- 5 files changed, 27 insertions(+), 50 deletions(-) create mode 100644 ergon_core/ergon_core/api/benchmark/__init__.py rename ergon_core/ergon_core/api/{ => benchmark}/benchmark.py (68%) create mode 100644 ergon_core/ergon_core/api/benchmark/requirements.py rename ergon_core/ergon_core/api/{task_types.py => benchmark/task.py} (77%) delete mode 100644 ergon_core/ergon_core/api/benchmark_deps.py diff --git a/ergon_core/ergon_core/api/benchmark/__init__.py b/ergon_core/ergon_core/api/benchmark/__init__.py new file mode 100644 index 00000000..b4205ee4 --- /dev/null +++ b/ergon_core/ergon_core/api/benchmark/__init__.py @@ -0,0 +1,7 @@ +"""Public benchmark authoring API.""" + +from ergon_core.api.benchmark.benchmark import Benchmark +from ergon_core.api.benchmark.requirements import BenchmarkRequirements +from ergon_core.api.benchmark.task import EmptyTaskPayload, Task + +__all__ = ["Benchmark", "BenchmarkRequirements", "Task", "EmptyTaskPayload"] diff --git a/ergon_core/ergon_core/api/benchmark.py b/ergon_core/ergon_core/api/benchmark/benchmark.py similarity index 68% rename from ergon_core/ergon_core/api/benchmark.py rename to ergon_core/ergon_core/api/benchmark/benchmark.py index e8869c41..2b369451 100644 --- a/ergon_core/ergon_core/api/benchmark.py +++ b/ergon_core/ergon_core/api/benchmark/benchmark.py @@ -1,28 +1,17 @@ -"""Public benchmark ABC. - -Uses ABCs (not Protocols) for discoverability via isinstance, template-method -helpers, and the HuggingFace "real classes" authoring feel. type_slug is -ClassVar because it identifies the CLASS for registry lookup and definition -persistence -- not a per-instance property. -""" +"""Public benchmark ABC.""" from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence from typing import Any, ClassVar +from ergon_core.api.benchmark.task import EmptyTaskPayload, Task from ergon_core.api.errors import DependencyError -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.core.runtime.dependencies import check_packages +from ergon_core.core.infrastructure.dependencies import check_packages from pydantic import BaseModel class Benchmark(ABC): - """Base class for all benchmarks. - - Subclasses MUST set ``type_slug`` and ``onboarding_deps`` and implement - ``build_instances``. Omitting ``onboarding_deps`` raises ``TypeError`` - at class definition time. - """ + """Base class for all benchmarks.""" type_slug: ClassVar[str] task_payload_model: ClassVar[type[BaseModel]] = EmptyTaskPayload @@ -48,19 +37,12 @@ def __init__( ] = dict(metadata or {}) @abstractmethod - def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[BaseModel]]]: - """Materialize benchmark instances. - - Returns a mapping of instance_key -> tasks for that instance. - """ + def build_instances(self) -> Mapping[str, Sequence[Task[BaseModel]]]: + """Materialize benchmark instances.""" ... def evaluator_requirements(self) -> Sequence[str]: - """Declare evaluator slot names required by this benchmark. - - Returns slot names (e.g. ``["default"]``) that ``Experiment.validate`` - checks are filled by the experiment's evaluator mapping. - """ + """Declare evaluator slot names required by this benchmark.""" return ("default",) @classmethod diff --git a/ergon_core/ergon_core/api/benchmark/requirements.py b/ergon_core/ergon_core/api/benchmark/requirements.py new file mode 100644 index 00000000..1a1cd3d8 --- /dev/null +++ b/ergon_core/ergon_core/api/benchmark/requirements.py @@ -0,0 +1,11 @@ +"""Onboarding dependency descriptor for Benchmark subclasses.""" + +from pydantic import BaseModel + + +class BenchmarkRequirements(BaseModel, frozen=True): + """Onboarding requirements for a single benchmark.""" + + e2b: bool = False + extras: tuple[str, ...] = () + optional_keys: tuple[str, ...] = () diff --git a/ergon_core/ergon_core/api/task_types.py b/ergon_core/ergon_core/api/benchmark/task.py similarity index 77% rename from ergon_core/ergon_core/api/task_types.py rename to ergon_core/ergon_core/api/benchmark/task.py index 61aa78b5..cfe51f04 100644 --- a/ergon_core/ergon_core/api/task_types.py +++ b/ergon_core/ergon_core/api/benchmark/task.py @@ -19,12 +19,8 @@ class EmptyTaskPayload(BaseModel): ) -class BenchmarkTask(BaseModel, Generic[PayloadT]): - """Unit of work passed to Worker.execute() and referenced in EvaluationContext. - - ``task_payload`` is benchmark-owned structured data. Benchmarks should - bind ``BenchmarkTask[TheirPayloadModel]`` instead of passing ad hoc dicts. - """ +class Task(BaseModel, Generic[PayloadT]): + """Unit of work passed to Worker.execute() and referenced in CriterionContext.""" model_config = {"frozen": True} diff --git a/ergon_core/ergon_core/api/benchmark_deps.py b/ergon_core/ergon_core/api/benchmark_deps.py deleted file mode 100644 index 95313c09..00000000 --- a/ergon_core/ergon_core/api/benchmark_deps.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Onboarding dependency descriptor for Benchmark subclasses.""" - -from pydantic import BaseModel - - -class BenchmarkDeps(BaseModel, frozen=True): - """Onboarding requirements for a single benchmark. - - Declared as a ClassVar on every Benchmark subclass. The onboarding - wizard reads these to determine which API keys to prompt for and - which pip extras to install. - - This is the single source of truth for a benchmark's onboarding - requirements. Do not add a corresponding entry in any dict elsewhere. - """ - - e2b: bool = False - extras: tuple[str, ...] = () - optional_keys: tuple[str, ...] = () From aed3d1cd07f187bb42a29524861741a0179ed394 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:44 +0100 Subject: [PATCH 44/66] refactor: split public criterion and rubric APIs Made-with: Cursor --- .../ergon_core/api/criterion/__init__.py | 19 +++ .../ergon_core/api/criterion/context.py | 99 +++++++++++++++ .../api/{ => criterion}/criterion.py | 19 ++- .../ergon_core/api/criterion/results.py | 70 +++++++++++ ergon_core/ergon_core/api/errors.py | 11 +- .../ergon_core/api/evaluation_context.py | 46 ------- ergon_core/ergon_core/api/evaluator.py | 114 ------------------ ergon_core/ergon_core/api/results.py | 103 ---------------- ergon_core/ergon_core/api/rubric/__init__.py | 7 ++ ergon_core/ergon_core/api/rubric/evaluator.py | 55 +++++++++ ergon_core/ergon_core/api/rubric/results.py | 20 +++ ergon_core/ergon_core/api/rubric/rubric.py | 62 ++++++++++ 12 files changed, 345 insertions(+), 280 deletions(-) create mode 100644 ergon_core/ergon_core/api/criterion/__init__.py create mode 100644 ergon_core/ergon_core/api/criterion/context.py rename ergon_core/ergon_core/api/{ => criterion}/criterion.py (74%) create mode 100644 ergon_core/ergon_core/api/criterion/results.py delete mode 100644 ergon_core/ergon_core/api/evaluation_context.py delete mode 100644 ergon_core/ergon_core/api/evaluator.py delete mode 100644 ergon_core/ergon_core/api/results.py create mode 100644 ergon_core/ergon_core/api/rubric/__init__.py create mode 100644 ergon_core/ergon_core/api/rubric/evaluator.py create mode 100644 ergon_core/ergon_core/api/rubric/results.py create mode 100644 ergon_core/ergon_core/api/rubric/rubric.py diff --git a/ergon_core/ergon_core/api/criterion/__init__.py b/ergon_core/ergon_core/api/criterion/__init__.py new file mode 100644 index 00000000..cba8c5aa --- /dev/null +++ b/ergon_core/ergon_core/api/criterion/__init__.py @@ -0,0 +1,19 @@ +"""Public criterion authoring API.""" + +from ergon_core.api.criterion.context import CriterionContext +from ergon_core.api.criterion.criterion import Criterion +from ergon_core.api.criterion.results import ( + CriterionEvidence, + CriterionOutcome, + EvidenceMessage, + ScoreScale, +) + +__all__ = [ + "Criterion", + "CriterionContext", + "CriterionOutcome", + "ScoreScale", + "CriterionEvidence", + "EvidenceMessage", +] diff --git a/ergon_core/ergon_core/api/criterion/context.py b/ergon_core/ergon_core/api/criterion/context.py new file mode 100644 index 00000000..0a48317d --- /dev/null +++ b/ergon_core/ergon_core/api/criterion/context.py @@ -0,0 +1,99 @@ +"""Public runtime-facing criterion context.""" + +from typing import Annotated, Any +from uuid import UUID + +from ergon_core.api.benchmark.task import Task +from ergon_core.api.worker.results import WorkerOutput +from ergon_core.core.application.evaluation.protocols import CriterionRuntime +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, SkipValidation + + +class CriterionContext(BaseModel): + """Task, worker output, and public criterion capabilities.""" + + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + run_id: UUID + task_id: UUID + execution_id: UUID + task: Task + worker_result: WorkerOutput + sandbox_id: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] + _runtime: Annotated[CriterionRuntime | None, SkipValidation] = PrivateAttr(default=None) + + def __init__(self, **data: Any) -> None: # slopcop: ignore[no-typing-any] + runtime = data.pop("runtime", None) + super().__init__(**data) + if runtime is not None: + object.__setattr__(self, "_runtime", runtime) + + def model_post_init(self, context: Any, /) -> None: # slopcop: ignore[no-typing-any] + if isinstance(context, dict) and "runtime" in context: + object.__setattr__(self, "_runtime", context["runtime"]) + + @classmethod + def with_runtime( + cls, + *, + runtime: CriterionRuntime | None, + **data: Any, # slopcop: ignore[no-typing-any] + ) -> "CriterionContext": + """Construct a context with runtime capabilities hidden from public fields.""" + instance = cls(**data) + object.__setattr__(instance, "_runtime", runtime) + return instance + + @property + def has_runtime(self) -> bool: + return self._runtime is not None + + @property + def runtime(self) -> CriterionRuntime | None: + """Private runtime capabilities exposed as a property, not a model field.""" + return self._runtime + + def _require_runtime(self) -> CriterionRuntime: + if self._runtime is None: + raise RuntimeError("CriterionRuntime not injected") + return self._runtime + + async def ensure_sandbox(self) -> None: + await self._require_runtime().ensure_sandbox() + + async def upload_files(self, files: list[dict]) -> None: + await self._require_runtime().upload_files(files) + + async def write_file(self, path: str, content: bytes) -> None: + await self._require_runtime().write_file(path, content) + + async def run_command(self, command: str, timeout: int = 30): + return await self._require_runtime().run_command(command, timeout) + + async def execute_code(self, code: str): + """Execute code through the internal criterion runtime.""" + return await self._require_runtime().execute_code(code) + + async def cleanup(self) -> None: + await self._require_runtime().cleanup() + + async def read_resource(self, name: str) -> bytes: + return await self._require_runtime().read_resource(name) + + async def read_resource_by_id(self, resource_id: UUID) -> bytes: + return await self._require_runtime().read_resource_by_id(resource_id) + + async def list_resources(self, task_execution_id: UUID | None = None): + return await self._require_runtime().list_resources(task_execution_id) + + async def get_all_files_for_task(self) -> dict[str, bytes]: + return await self._require_runtime().get_all_files_for_task() + + async def list_files(self, path: str = "."): + """List files through the internal criterion runtime.""" + return await self.run_command(f"find {path} -maxdepth 1 -type f", timeout=30) + + async def read_file(self, path: str) -> str: + """Read a file through the internal criterion runtime.""" + return (await self.read_resource(path)).decode("utf-8") diff --git a/ergon_core/ergon_core/api/criterion.py b/ergon_core/ergon_core/api/criterion/criterion.py similarity index 74% rename from ergon_core/ergon_core/api/criterion.py rename to ergon_core/ergon_core/api/criterion/criterion.py index 46e6268a..45861793 100644 --- a/ergon_core/ergon_core/api/criterion.py +++ b/ergon_core/ergon_core/api/criterion/criterion.py @@ -4,17 +4,14 @@ from collections.abc import Mapping from typing import Any, ClassVar +from ergon_core.api.criterion.context import CriterionContext +from ergon_core.api.criterion.results import CriterionOutcome, ScoreScale from ergon_core.api.errors import DependencyError -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult, CriterionScoreSpec -from ergon_core.core.runtime.dependencies import check_packages +from ergon_core.core.infrastructure.dependencies import check_packages class Criterion(ABC): - """Atomic evaluation unit that owns its own data-pulling and verification logic. - - Subclasses must set ``type_slug`` and implement ``evaluate``. - """ + """Atomic evaluation unit that owns its own data-pulling and verification logic.""" type_slug: ClassVar[str] required_packages: ClassVar[list[str]] = [] @@ -26,20 +23,20 @@ def __init__( slug: str, description: str | None = None, weight: float = 1.0, - score_spec: CriterionScoreSpec | None = None, + score_spec: ScoreScale | None = None, metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] ) -> None: self.slug = slug self.description = description or slug self.weight = weight - self.score_spec = score_spec or CriterionScoreSpec() + self.score_spec = score_spec or ScoreScale() self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] @abstractmethod async def evaluate( self, - context: EvaluationContext, - ) -> CriterionResult: + context: CriterionContext, + ) -> CriterionOutcome: """Run one atomic evaluation against the provided context.""" ... diff --git a/ergon_core/ergon_core/api/criterion/results.py b/ergon_core/ergon_core/api/criterion/results.py new file mode 100644 index 00000000..2540d7af --- /dev/null +++ b/ergon_core/ergon_core/api/criterion/results.py @@ -0,0 +1,70 @@ +"""Public criterion result models.""" + +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + +JsonObject = dict[str, Any] # slopcop: ignore[no-typing-any] -- public JSON-like metadata bag + + +class ScoreScale(BaseModel): + """Criterion-local score range.""" + + model_config = {"frozen": True} + + min_score: float = 0.0 + max_score: float = 1.0 + + +class EvidenceMessage(BaseModel): + """One prompt-like message used while producing criterion evidence.""" + + model_config = {"frozen": True} + + role: Literal["system", "user", "assistant", "tool"] + content: str + + +class CriterionEvidence(BaseModel): + """Structured evidence space for a criterion run.""" + + model_config = {"frozen": True} + + prompt_messages: list[EvidenceMessage] = Field(default_factory=list) + evidence_resource_ids: list[str] = Field(default_factory=list) + evidence_action_ids: list[str] = Field(default_factory=list) + output: JsonObject | None = None + model: str | None = None + details: JsonObject = Field(default_factory=dict) + + +class CriterionOutcome(BaseModel): + """Result of a single Criterion.evaluate() invocation.""" + + model_config = {"frozen": True} + + slug: str + name: str + score: float + passed: bool + weight: float = 1.0 + max_score: float = 1.0 + feedback: str | None = None + model_reasoning: str | None = None + skipped_reason: str | None = None + evaluation_input: str | None = None + evaluated_action_ids: list[str] = Field(default_factory=list) + evaluated_resource_ids: list[str] = Field(default_factory=list) + observation: CriterionEvidence | None = None + error: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] + metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] + + @model_validator(mode="before") + @classmethod + def _populate_slug_name(cls, data): + if isinstance(data, dict): + if "slug" not in data and "name" in data: + data["slug"] = data["name"] + if "name" not in data and "slug" in data: + data["name"] = data["slug"] + return data diff --git a/ergon_core/ergon_core/api/errors.py b/ergon_core/ergon_core/api/errors.py index ced6ea65..d8c1d481 100644 --- a/ergon_core/ergon_core/api/errors.py +++ b/ergon_core/ergon_core/api/errors.py @@ -5,12 +5,11 @@ class DependencyError(Exception): """A component's required package is not installed.""" -class CriteriaCheckError(Exception): +class CriterionCheckError(Exception): """A criterion rejected the run for domain reasons (shape, probes, content). - Implementations such as :class:`~ergon_core.api.criterion.Criterion` - subclasses may raise this from verification helpers; the criterion's - ``evaluate`` method is expected to catch it and return - ``CriterionResult(passed=False, …)``. Bugs and infrastructure failures - should use other exception types so they propagate loudly. + Implementations such as :class:`~ergon_core.api.criterion.Criterion` subclasses may raise this + from verification helpers; the criterion's ``evaluate`` method is expected to catch it and + return ``CriterionOutcome(passed=False, ...)``. Bugs and infrastructure failures should use + other exception types so they propagate loudly. """ diff --git a/ergon_core/ergon_core/api/evaluation_context.py b/ergon_core/ergon_core/api/evaluation_context.py deleted file mode 100644 index 2df27cf1..00000000 --- a/ergon_core/ergon_core/api/evaluation_context.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Public runtime-facing evaluation context.""" - -from typing import Annotated, Any -from uuid import UUID - -from ergon_core.api.results import WorkerOutput -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.evaluation.protocols import CriterionRuntime -from pydantic import BaseModel, ConfigDict, Field, SkipValidation - - -class EvaluationContext(BaseModel): - """Thin evaluation context: sandbox identity + capabilities + task identity. - - Thin by design. Criteria own their data-pulling -- they connect to the - sandbox via ``sandbox_id`` and pull what they need. The old pattern - pre-collected resources, which broke agentic evaluators that need to - explore freely. - - ``sandbox_id`` is the *identity* of the sandbox the worker used. A - criterion alone cannot do anything with it -- creating a client or - running a command requires the sandbox manager. Rather than giving - every criterion its own handle on the sandbox provider stack, the - executor wraps those capabilities in a ``CriterionRuntime`` and - injects it here. The runtime owns the sandbox lifecycle; criteria - that need sandbox evidence call methods like ``execute_code(...)``. - LLM-as-judge criteria own their provider call and prompt policy - outside this runtime. - """ - - # ``CriterionRuntime`` is a ``typing.Protocol``. Pydantic's synthesised - # validator for Protocols is overly strict (isinstance checks that fail - # for AsyncMocks etc.), and the field is never serialised, so we use - # ``SkipValidation`` -- the type hint remains for static checkers and - # editor autocompletion while runtime validation is bypassed. - # ``arbitrary_types_allowed`` is still required for model construction. - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - run_id: UUID - task_id: UUID - execution_id: UUID - task: BenchmarkTask - worker_result: WorkerOutput - sandbox_id: str | None = None - metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] - runtime: Annotated[CriterionRuntime | None, SkipValidation] = None diff --git a/ergon_core/ergon_core/api/evaluator.py b/ergon_core/ergon_core/api/evaluator.py deleted file mode 100644 index 122a2448..00000000 --- a/ergon_core/ergon_core/api/evaluator.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Public evaluator ABC and Rubric concrete implementation.""" - -from abc import ABC, abstractmethod -from collections.abc import Iterable, Mapping -from typing import Any, ClassVar - -from ergon_core.api.criterion import Criterion -from ergon_core.api.errors import DependencyError -from ergon_core.api.results import CriterionResult, TaskEvaluationResult -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.dependencies import check_packages - - -class Evaluator(ABC): - """Base class for all evaluators. - - Subclasses must set ``type_slug`` and implement ``criteria_for`` and - ``aggregate_task``. - """ - - type_slug: ClassVar[str] - required_packages: ClassVar[list[str]] = [] - install_hint: ClassVar[str] = "" - - def __init__( - self, - *, - name: str, - metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] - ) -> None: - self.name = name - self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] - - @abstractmethod - def criteria_for(self, task: BenchmarkTask) -> Iterable[Criterion]: - """Resolve the criterion set to run for *task*.""" - ... - - @abstractmethod - def aggregate_task( - self, - task: BenchmarkTask, - criterion_results: Iterable[CriterionResult], - ) -> TaskEvaluationResult: - """Aggregate criterion-level outputs into one task-level result.""" - ... - - def validate(self) -> None: - """Check that runtime dependencies are available.""" - errors = check_packages( - self.required_packages, - f"Evaluator '{self.type_slug}'", - ) - if errors: - parts = [*errors] - if self.install_hint: - parts.append(f"Install with: {self.install_hint}") - raise DependencyError("\n".join(parts)) - - -class Rubric(Evaluator): - """Concrete evaluator with a fixed criteria list. - - Aggregates scores using weighted averages. - """ - - def __init__( - self, - *, - name: str, - criteria: Iterable[Criterion], - metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] - ) -> None: - super().__init__(name=name, metadata=metadata) - self.criteria: tuple[Criterion, ...] = tuple(criteria) - - def criteria_for(self, task: BenchmarkTask) -> Iterable[Criterion]: - return self.criteria - - def aggregate_task( - self, - task: BenchmarkTask, - criterion_results: Iterable[CriterionResult], - ) -> TaskEvaluationResult: - results = list(criterion_results) - if not results: - return TaskEvaluationResult( - task_slug=task.task_slug, - score=0.0, - passed=False, - evaluator_name=self.name, - criterion_results=results, - feedback="No criterion results to aggregate.", - ) - - total_weight = sum(r.weight for r in results) - if total_weight == 0: - weighted_score = 0.0 - else: - weighted_score = sum(r.score * r.weight for r in results) / total_weight - - all_passed = all(r.passed for r in results) - return TaskEvaluationResult( - task_slug=task.task_slug, - score=weighted_score, - passed=all_passed, - evaluator_name=self.name, - criterion_results=results, - ) - - def validate(self) -> None: - super().validate() - for criterion in self.criteria: - criterion.validate() diff --git a/ergon_core/ergon_core/api/results.py b/ergon_core/ergon_core/api/results.py deleted file mode 100644 index 7d58216b..00000000 --- a/ergon_core/ergon_core/api/results.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Public result types returned by workers, criteria, and evaluators.""" - -from typing import Any, Literal - -from pydantic import BaseModel, Field, model_validator - -from ergon_core.core.json_types import JsonObject - - -class WorkerOutput(BaseModel): - """Final output of a worker execution. - - The worker's ``execute()`` async generator yields ``ContextPartChunk`` - objects (enriched and persisted individually). After the generator exhausts, - ``Worker.get_output()`` returns this model with the execution summary. - """ - - model_config = {"frozen": True} - - output: str - success: bool = True - metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] - - -class CriterionScoreSpec(BaseModel): - """Criterion-local score range. - - This is the range an atomic criterion can emit. Aggregation penalties and - signed weights are evaluator/rubric concerns, not negative local scores. - """ - - model_config = {"frozen": True} - - min_score: float = 0.0 - max_score: float = 1.0 - - -class CriterionObservationMessage(BaseModel): - """One prompt-like message used while producing a criterion result.""" - - model_config = {"frozen": True} - - role: Literal["system", "user", "assistant", "tool"] - content: str - - -class CriterionObservation(BaseModel): - """Structured observation space for a criterion run.""" - - model_config = {"frozen": True} - - prompt_messages: list[CriterionObservationMessage] = Field(default_factory=list) - evidence_resource_ids: list[str] = Field(default_factory=list) - evidence_action_ids: list[str] = Field(default_factory=list) - output: JsonObject | None = None - model: str | None = None - details: JsonObject = Field(default_factory=dict) - - -class CriterionResult(BaseModel): - """Result of a single Criterion.evaluate() invocation.""" - - model_config = {"frozen": True} - - slug: str - name: str - score: float - passed: bool - weight: float = 1.0 - max_score: float = 1.0 - feedback: str | None = None - model_reasoning: str | None = None - skipped_reason: str | None = None - evaluation_input: str | None = None - evaluated_action_ids: list[str] = Field(default_factory=list) - evaluated_resource_ids: list[str] = Field(default_factory=list) - observation: CriterionObservation | None = None - error: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] - metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] - - @model_validator(mode="before") - @classmethod - def _populate_slug_name(cls, data): - if isinstance(data, dict): - if "slug" not in data and "name" in data: - data["slug"] = data["name"] - if "name" not in data and "slug" in data: - data["name"] = data["slug"] - return data - - -class TaskEvaluationResult(BaseModel): - """Aggregated evaluation result for one task across all criteria.""" - - model_config = {"frozen": True} - - task_slug: str - score: float - passed: bool - evaluator_name: str - criterion_results: list[CriterionResult] = Field(default_factory=list) - feedback: str | None = None - metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] diff --git a/ergon_core/ergon_core/api/rubric/__init__.py b/ergon_core/ergon_core/api/rubric/__init__.py new file mode 100644 index 00000000..712c52bd --- /dev/null +++ b/ergon_core/ergon_core/api/rubric/__init__.py @@ -0,0 +1,7 @@ +"""Public rubric authoring API.""" + +from ergon_core.api.rubric.evaluator import Evaluator +from ergon_core.api.rubric.results import TaskEvaluationResult +from ergon_core.api.rubric.rubric import Rubric + +__all__ = ["Evaluator", "Rubric", "TaskEvaluationResult"] diff --git a/ergon_core/ergon_core/api/rubric/evaluator.py b/ergon_core/ergon_core/api/rubric/evaluator.py new file mode 100644 index 00000000..19a98051 --- /dev/null +++ b/ergon_core/ergon_core/api/rubric/evaluator.py @@ -0,0 +1,55 @@ +"""Public advanced evaluator ABC.""" + +from abc import ABC, abstractmethod +from collections.abc import Iterable, Mapping +from typing import Any, ClassVar + +from ergon_core.api.benchmark.task import Task +from ergon_core.api.criterion.criterion import Criterion +from ergon_core.api.criterion.results import CriterionOutcome +from ergon_core.api.errors import DependencyError +from ergon_core.api.rubric.results import TaskEvaluationResult +from ergon_core.core.infrastructure.dependencies import check_packages + + +class Evaluator(ABC): + """Base class for custom dynamic evaluators.""" + + type_slug: ClassVar[str] + required_packages: ClassVar[list[str]] = [] + install_hint: ClassVar[str] = "" + + def __init__( + self, + *, + name: str, + metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] + ) -> None: + self.name = name + self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] + + @abstractmethod + def criteria_for(self, task: Task) -> Iterable[Criterion]: + """Resolve the criterion set to run for *task*.""" + ... + + @abstractmethod + def aggregate_task( + self, + task: Task, + criterion_results: Iterable[CriterionOutcome], + ) -> TaskEvaluationResult: + """Aggregate criterion-level outputs into one task-level result.""" + ... + + def validate(self) -> None: + """Check that runtime dependencies are available.""" + errors = check_packages( + self.required_packages, + f"Evaluator '{self.type_slug}'", + ) + if errors: + parts = [*errors] + if self.install_hint: + parts.append(f"Install with: {self.install_hint}") + raise DependencyError("\n".join(parts)) diff --git a/ergon_core/ergon_core/api/rubric/results.py b/ergon_core/ergon_core/api/rubric/results.py new file mode 100644 index 00000000..5e8911e3 --- /dev/null +++ b/ergon_core/ergon_core/api/rubric/results.py @@ -0,0 +1,20 @@ +"""Public rubric result models.""" + +from typing import Any + +from ergon_core.api.criterion.results import CriterionOutcome +from pydantic import BaseModel, Field + + +class TaskEvaluationResult(BaseModel): + """Aggregated evaluation result for one task across all criteria.""" + + model_config = {"frozen": True} + + task_slug: str + score: float + passed: bool + evaluator_name: str + criterion_results: list[CriterionOutcome] = Field(default_factory=list) + feedback: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] diff --git a/ergon_core/ergon_core/api/rubric/rubric.py b/ergon_core/ergon_core/api/rubric/rubric.py new file mode 100644 index 00000000..42ee3c48 --- /dev/null +++ b/ergon_core/ergon_core/api/rubric/rubric.py @@ -0,0 +1,62 @@ +"""Public fixed-criteria rubric implementation.""" + +from collections.abc import Iterable, Mapping +from typing import Any + +from ergon_core.api.benchmark.task import Task +from ergon_core.api.criterion.criterion import Criterion +from ergon_core.api.criterion.results import CriterionOutcome +from ergon_core.api.rubric.evaluator import Evaluator +from ergon_core.api.rubric.results import TaskEvaluationResult + + +class Rubric(Evaluator): + """Concrete evaluator with a fixed criteria list.""" + + def __init__( + self, + *, + name: str, + criteria: Iterable[Criterion], + metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] + ) -> None: + super().__init__(name=name, metadata=metadata) + self.criteria: tuple[Criterion, ...] = tuple(criteria) + + def criteria_for(self, task: Task) -> Iterable[Criterion]: + return self.criteria + + def aggregate_task( + self, + task: Task, + criterion_results: Iterable[CriterionOutcome], + ) -> TaskEvaluationResult: + results = list(criterion_results) + if not results: + return TaskEvaluationResult( + task_slug=task.task_slug, + score=0.0, + passed=False, + evaluator_name=self.name, + criterion_results=results, + feedback="No criterion results to aggregate.", + ) + + total_weight = sum(r.weight for r in results) + if total_weight == 0: + weighted_score = 0.0 + else: + weighted_score = sum(r.score * r.weight for r in results) / total_weight + + return TaskEvaluationResult( + task_slug=task.task_slug, + score=weighted_score, + passed=all(r.passed for r in results), + evaluator_name=self.name, + criterion_results=results, + ) + + def validate(self) -> None: + super().validate() + for criterion in self.criteria: + criterion.validate() From 2dd0e123a07a06b10cfef06c073bf32a64b86d10 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:44 +0100 Subject: [PATCH 45/66] refactor: split public worker API package Made-with: Cursor --- ergon_core/ergon_core/api/handles.py | 27 ----- ergon_core/ergon_core/api/worker.py | 112 ------------------ ergon_core/ergon_core/api/worker/__init__.py | 7 ++ .../{worker_context.py => worker/context.py} | 6 +- ergon_core/ergon_core/api/worker/results.py | 15 +++ ergon_core/ergon_core/api/worker/worker.py | 71 +++++++++++ ergon_core/ergon_core/api/worker_spec.py | 79 ------------ 7 files changed, 94 insertions(+), 223 deletions(-) delete mode 100644 ergon_core/ergon_core/api/handles.py delete mode 100644 ergon_core/ergon_core/api/worker.py create mode 100644 ergon_core/ergon_core/api/worker/__init__.py rename ergon_core/ergon_core/api/{worker_context.py => worker/context.py} (84%) create mode 100644 ergon_core/ergon_core/api/worker/results.py create mode 100644 ergon_core/ergon_core/api/worker/worker.py delete mode 100644 ergon_core/ergon_core/api/worker_spec.py diff --git a/ergon_core/ergon_core/api/handles.py b/ergon_core/ergon_core/api/handles.py deleted file mode 100644 index 5cc8a931..00000000 --- a/ergon_core/ergon_core/api/handles.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Public lifecycle handle types returned by Experiment.persist().""" - -from datetime import datetime -from typing import Any -from uuid import UUID - -from ergon_core.core.utils import utcnow -from pydantic import BaseModel, Field - - -class PersistedExperimentDefinition(BaseModel): - """Rich handle returned by Experiment.persist(). - - Carries enough information for inspection, logging, and downstream use - without requiring a database round-trip. - """ - - model_config = {"frozen": True} - - definition_id: UUID - benchmark_type: str - worker_bindings: dict[str, str] = Field(default_factory=dict) - evaluator_bindings: dict[str, str] = Field(default_factory=dict) - instance_count: int = 0 - task_count: int = 0 - created_at: datetime = Field(default_factory=utcnow) - metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] diff --git a/ergon_core/ergon_core/api/worker.py b/ergon_core/ergon_core/api/worker.py deleted file mode 100644 index 28101daf..00000000 --- a/ergon_core/ergon_core/api/worker.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Public worker ABC.""" - -from abc import ABC, abstractmethod -from collections.abc import AsyncGenerator, Mapping -from typing import Any, ClassVar, Self -from uuid import UUID - -from ergon_core.api.errors import DependencyError -from ergon_core.api.results import WorkerOutput -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.api.worker_context import WorkerContext -from ergon_core.core.generation import AssistantTextPart, ContextPartChunk -from ergon_core.core.persistence.context.repository import ContextEventRepository -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.runtime.dependencies import check_packages -from sqlmodel import Session - - -class Worker(ABC): - """Base class for all workers. - - Subclasses must set ``type_slug`` and implement ``execute`` as an - async generator that yields ``ContextPartChunk`` objects. - """ - - type_slug: ClassVar[str] - required_packages: ClassVar[list[str]] = [] - install_hint: ClassVar[str] = "" - - def __init__( - self, - *, - name: str, - model: str | None, - task_id: UUID, - sandbox_id: str, - metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] - ) -> None: - # reason: RFC 2026-04-22 §1 — ``Worker`` is execution-ready only; - # ``task_id`` / ``sandbox_id`` are required so every concrete worker - # has the identity it needs at execute time. Config-time composition - # uses ``WorkerSpec`` (ergon_core.api.worker_spec) which carries the - # descriptor-only fields and never constructs a ``Worker`` directly. - self.name = name - self.model = model - self.task_id = task_id - self.sandbox_id = sandbox_id - self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] - self._context_repo = ContextEventRepository() - - @abstractmethod - async def execute( - self, - task: BenchmarkTask, - *, - context: WorkerContext, - ) -> AsyncGenerator[ContextPartChunk, None]: - """Run the worker's task behavior, yielding context chunks as they occur. - - Each yielded ContextPartChunk is enriched and persisted by the runtime. - """ - ... - yield # type: ignore[misc] - - @classmethod - def from_buffer( - cls, - execution_id: UUID, - session: Session, - **kwargs: Any, # slopcop: ignore[no-typing-any] - ) -> Self | None: - """Construct a worker pre-seeded with context event history. - - Returns a new worker instance whose ``execute()`` will continue - from where the previous execution left off, or ``None`` if this - worker type doesn't support resumption. - """ - return None - - def get_output(self, context: WorkerContext) -> WorkerOutput: - """Build output from persisted context chunks. Override for custom output. - - Called by the runtime after the async generator is fully consumed. - Default reads context events from PG via ``self._context_repo`` and returns - the last assistant text. Workers that need structured output, - summaries, or custom logic override this. - """ - with get_session() as session: - events = self._context_repo.get_for_execution(session, context.execution_id) - text_events = [] - for event in events: - if event.event_type != "assistant_text": - continue - payload = event.parsed_payload() - if isinstance(payload.part, AssistantTextPart): - text_events.append(payload.part.content) - return WorkerOutput( - output=text_events[-1] if text_events else "", - success=True, - ) - - def validate(self) -> None: - """Check that runtime dependencies are available.""" - errors = check_packages( - self.required_packages, - f"Worker '{self.type_slug}'", - ) - if errors: - parts = [*errors] - if self.install_hint: - parts.append(f"Install with: {self.install_hint}") - raise DependencyError("\n".join(parts)) diff --git a/ergon_core/ergon_core/api/worker/__init__.py b/ergon_core/ergon_core/api/worker/__init__.py new file mode 100644 index 00000000..021f3615 --- /dev/null +++ b/ergon_core/ergon_core/api/worker/__init__.py @@ -0,0 +1,7 @@ +"""Public worker authoring API.""" + +from ergon_core.api.worker.context import WorkerContext +from ergon_core.api.worker.results import WorkerOutput +from ergon_core.api.worker.worker import Worker, WorkerStreamItem + +__all__ = ["Worker", "WorkerContext", "WorkerOutput", "WorkerStreamItem"] diff --git a/ergon_core/ergon_core/api/worker_context.py b/ergon_core/ergon_core/api/worker/context.py similarity index 84% rename from ergon_core/ergon_core/api/worker_context.py rename to ergon_core/ergon_core/api/worker/context.py index 4bde7755..3a02ad05 100644 --- a/ergon_core/ergon_core/api/worker_context.py +++ b/ergon_core/ergon_core/api/worker/context.py @@ -7,11 +7,7 @@ class WorkerContext(BaseModel): - """Runtime context for a single worker execution. - - Contains only per-execution state that the worker cannot know at - construction time. Tools and configuration belong on the Worker itself. - """ + """Runtime context for a single worker execution.""" model_config = {"frozen": True} diff --git a/ergon_core/ergon_core/api/worker/results.py b/ergon_core/ergon_core/api/worker/results.py new file mode 100644 index 00000000..fe0cba8d --- /dev/null +++ b/ergon_core/ergon_core/api/worker/results.py @@ -0,0 +1,15 @@ +"""Public worker result models.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class WorkerOutput(BaseModel): + """Final output of a worker execution.""" + + model_config = {"frozen": True} + + output: str + success: bool = True + metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] diff --git a/ergon_core/ergon_core/api/worker/worker.py b/ergon_core/ergon_core/api/worker/worker.py new file mode 100644 index 00000000..cdfcfdb2 --- /dev/null +++ b/ergon_core/ergon_core/api/worker/worker.py @@ -0,0 +1,71 @@ +"""Public worker ABC.""" + +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator, Mapping +from typing import Any, ClassVar, Self, cast +from uuid import UUID + +from ergon_core.api.benchmark.task import Task +from ergon_core.api.errors import DependencyError +from ergon_core.api.worker.context import WorkerContext +from ergon_core.api.worker.results import WorkerOutput +from ergon_core.core.domain.generation.context_parts import ContextPartChunk +from ergon_core.core.infrastructure.dependencies import check_packages + +WorkerStreamItem = ContextPartChunk | WorkerOutput + + +class Worker(ABC): + """Base class for all workers.""" + + type_slug: ClassVar[str] + required_packages: ClassVar[list[str]] = [] + install_hint: ClassVar[str] = "" + + def __init__( + self, + *, + name: str, + model: str | None, + task_id: UUID, + sandbox_id: str, + metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] + ) -> None: + self.name = name + self.model = model + self.task_id = task_id + self.sandbox_id = sandbox_id + self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] + + @abstractmethod + async def execute( + self, + task: Task, + *, + context: WorkerContext, + ) -> AsyncGenerator[WorkerStreamItem, None]: + """Run the worker, yielding context chunks and a terminal WorkerOutput.""" + raise NotImplementedError + yield cast(WorkerStreamItem, None) + + @classmethod + def from_buffer( + cls, + execution_id: UUID, + session: Any, # slopcop: ignore[no-typing-any] -- runtime owns concrete session type + **kwargs: Any, # slopcop: ignore[no-typing-any] + ) -> Self | None: + """Construct a worker pre-seeded with context event history.""" + return None + + def validate(self) -> None: + """Check that runtime dependencies are available.""" + errors = check_packages( + self.required_packages, + f"Worker '{self.type_slug}'", + ) + if errors: + parts = [*errors] + if self.install_hint: + parts.append(f"Install with: {self.install_hint}") + raise DependencyError("\n".join(parts)) diff --git a/ergon_core/ergon_core/api/worker_spec.py b/ergon_core/ergon_core/api/worker_spec.py deleted file mode 100644 index 37756f57..00000000 --- a/ergon_core/ergon_core/api/worker_spec.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Config-time descriptor for a worker binding. - -A ``WorkerSpec`` records *what kind of worker* an experiment wants and -*how it should be named / targeted at a model* — without requiring any of -the runtime identity (``task_id`` / ``sandbox_id``) that a live -``Worker`` instance needs to actually execute. - -Rationale (RFC 2026-04-22, Open Question 1 resolution): - -* ``Experiment`` is built once at config time, long before any task - exists and any sandbox has been provisioned. Asking users to hand us a - fully-constructed ``Worker`` there forces us to either make the - identity fields optional (sentinel values like ``UUID(int=0)`` / ``""`` - leak into the runtime) or construct ``Worker`` instances twice (once - config-side for the Experiment graph, once exec-side with real IDs). -* ``WorkerSpec`` is the honest type for the config layer: three fields, - no runtime state, trivially serialisable, no registry plumbing. The - registry factory is invoked exactly once — at ``worker_execute`` time, - with the real ``task_id`` and ``sandbox_id`` — and the fresh ``Worker`` - lives only for the duration of that execution. - -See also: ``ergon_core/api/worker.py`` for the execution-time ``Worker`` -ABC (now requires ``task_id`` / ``sandbox_id`` at construction). -""" - -from pydantic import BaseModel, ConfigDict - - -class WorkerSpec(BaseModel): - """Immutable descriptor for a worker binding in an ``Experiment``. - - Attributes - ---------- - worker_slug - Registry key — must be present in ``ergon_builtins.registry.WORKERS``. - Used at execute time to resolve the concrete ``Worker`` class or - benchmark factory. - name - Binding key / instance name for the worker. Persisted into the - definition snapshot and used as the binding key if the Experiment - is constructed via ``Experiment.from_single_worker``. - model - Model target identifier (provider-qualified, e.g. - ``"openai:gpt-4o"``). This is required at the experiment composition - boundary so persisted definitions are fully explicit. Workers that - do not call an LLM still receive the configured model target; they - can ignore it at execution time. - """ - - # reason: project standard (slopcop `no-dataclass`) is Pydantic BaseModel; - # frozen=True preserves the dataclass-style immutability we want. - model_config = ConfigDict(frozen=True) - - worker_slug: str - name: str - model: str - - def validate_spec(self) -> None: - """Check that ``worker_slug`` refers to a known registry entry. - - Kept deliberately lightweight — model-target validation happens - at execution time inside the generation providers, and name - validation is structural (any non-empty string works). - - Named ``validate_spec`` (not ``validate``) to avoid shadowing - ``pydantic.BaseModel.validate`` (deprecated but still present). - """ - # Deferred: avoid import cycle — ergon_builtins imports ergon_core.api. - from ergon_builtins.registry import WORKERS - - if self.worker_slug not in WORKERS: - known = ", ".join(sorted(WORKERS)) - raise ValueError( - f"Unknown worker slug {self.worker_slug!r}; registered workers: {known}" - ) - if not self.name: - raise ValueError("WorkerSpec.name must be a non-empty string") - if not self.model: - raise ValueError("WorkerSpec.model must be a non-empty string") From 4f18c87c8e11cddd7f709293fcd5fc11ecc818a0 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:44 +0100 Subject: [PATCH 46/66] refactor: introduce explicit component registry API Made-with: Cursor --- ergon_core/ergon_core/api/__init__.py | 50 +++++++++--------- ergon_core/ergon_core/api/registry.py | 73 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 24 deletions(-) create mode 100644 ergon_core/ergon_core/api/registry.py diff --git a/ergon_core/ergon_core/api/__init__.py b/ergon_core/ergon_core/api/__init__.py index 6d775832..e3762141 100644 --- a/ergon_core/ergon_core/api/__init__.py +++ b/ergon_core/ergon_core/api/__init__.py @@ -1,36 +1,38 @@ -"""Object-first Ergon public API surface.""" +"""Beginner-facing Ergon authoring API surface.""" -from ergon_core.api.benchmark import Benchmark -from ergon_core.api.benchmark_deps import BenchmarkDeps -from ergon_core.api.criterion import Criterion -from ergon_core.api.errors import CriteriaCheckError, DependencyError -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.evaluator import Evaluator, Rubric -from ergon_core.api.experiment import Experiment -from ergon_core.api.handles import PersistedExperimentDefinition -from ergon_core.api.results import CriterionResult, TaskEvaluationResult, WorkerOutput -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.api.worker import Worker -from ergon_core.api.worker_context import WorkerContext -from ergon_core.api.worker_spec import WorkerSpec +from ergon_core.api.benchmark import Benchmark, BenchmarkRequirements, EmptyTaskPayload, Task +from ergon_core.api.criterion import ( + Criterion, + CriterionContext, + CriterionEvidence, + CriterionOutcome, + EvidenceMessage, + ScoreScale, +) +from ergon_core.api.errors import CriterionCheckError +from ergon_core.api.registry import ComponentRegistry, WorkerFactory, registry +from ergon_core.api.rubric import Rubric, TaskEvaluationResult +from ergon_core.api.worker import Worker, WorkerContext, WorkerOutput, WorkerStreamItem __all__ = [ "Benchmark", - "BenchmarkDeps", - "BenchmarkTask", + "BenchmarkRequirements", + "ComponentRegistry", "Criterion", - "CriterionResult", - "CriteriaCheckError", - "DependencyError", - "EvaluationContext", - "Evaluator", - "Experiment", + "CriterionCheckError", + "CriterionContext", + "CriterionEvidence", + "CriterionOutcome", "EmptyTaskPayload", - "PersistedExperimentDefinition", + "EvidenceMessage", "Rubric", + "ScoreScale", + "Task", "TaskEvaluationResult", "Worker", "WorkerContext", + "WorkerFactory", "WorkerOutput", - "WorkerSpec", + "WorkerStreamItem", + "registry", ] diff --git a/ergon_core/ergon_core/api/registry.py b/ergon_core/ergon_core/api/registry.py new file mode 100644 index 00000000..24c70b87 --- /dev/null +++ b/ergon_core/ergon_core/api/registry.py @@ -0,0 +1,73 @@ +"""Public process-level component registry. + +The registry maps stable slugs stored in experiment definitions back to the +Python classes/factories needed by runtime jobs. Packages such as +``ergon_builtins`` and test fixtures contribute components explicitly during +startup; ``ergon_core`` never imports those packages to discover components. +""" + +from collections.abc import Callable, Mapping +from typing import TypeVar + +from ergon_core.api.benchmark import Benchmark +from ergon_core.api.rubric import Evaluator +from ergon_core.api.worker import Worker +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager +from pydantic import BaseModel, ConfigDict, Field + +WorkerFactory = Callable[..., Worker] +T = TypeVar("T") + + +class ComponentRegistry(BaseModel): + """Catalog of component types available in the current Python process.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + workers: dict[str, WorkerFactory] = Field(default_factory=dict) + benchmarks: dict[str, type[Benchmark]] = Field(default_factory=dict) + evaluators: dict[str, type[Evaluator]] = Field(default_factory=dict) + sandbox_managers: dict[str, type[BaseSandboxManager]] = Field(default_factory=dict) + + def register_worker(self, slug: str, factory: WorkerFactory) -> None: + self._register(self.workers, "worker", slug, factory) + + def register_benchmark(self, benchmark_cls: type[Benchmark], slug: str | None = None) -> None: + self._register(self.benchmarks, "benchmark", slug or benchmark_cls.type_slug, benchmark_cls) + + def register_evaluator(self, evaluator_cls: type[Evaluator], slug: str | None = None) -> None: + self._register(self.evaluators, "evaluator", slug or evaluator_cls.type_slug, evaluator_cls) + + def register_sandbox_manager( + self, + slug: str, + manager_cls: type[BaseSandboxManager], + ) -> None: + self._register(self.sandbox_managers, "sandbox manager", slug, manager_cls) + + def require_worker(self, slug: str) -> WorkerFactory: + return self._require(self.workers, "worker", slug) + + def require_benchmark(self, slug: str) -> type[Benchmark]: + return self._require(self.benchmarks, "benchmark", slug) + + def require_evaluator(self, slug: str) -> type[Evaluator]: + return self._require(self.evaluators, "evaluator", slug) + + def _register(self, target: dict[str, T], kind: str, slug: str, value: T) -> None: + existing = target.get(slug) + if existing is not None and existing is not value: + raise ValueError(f"Duplicate {kind} slug {slug!r}") + target[slug] = value + + def _require(self, target: Mapping[str, T], kind: str, slug: str) -> T: + try: + return target[slug] + except KeyError: + known = ", ".join(sorted(target)) or "" + raise ValueError( + f"Unknown {kind} slug {slug!r}; registered {kind}s: {known}" + ) from None + + +registry = ComponentRegistry() From 8839ac2c1893ffb345dbc6e1c3a050b6b62a2735 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:44 +0100 Subject: [PATCH 47/66] refactor: move shared core utilities into core shared package Made-with: Cursor --- ergon_core/ergon_core/core/__init__.py | 1 + ergon_core/ergon_core/core/shared/__init__.py | 0 ergon_core/ergon_core/core/{ => shared}/json_types.py | 0 ergon_core/ergon_core/core/{ => shared}/settings.py | 2 +- ergon_core/ergon_core/core/{ => shared}/utils.py | 0 5 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 ergon_core/ergon_core/core/__init__.py create mode 100644 ergon_core/ergon_core/core/shared/__init__.py rename ergon_core/ergon_core/core/{ => shared}/json_types.py (100%) rename ergon_core/ergon_core/core/{ => shared}/settings.py (97%) rename ergon_core/ergon_core/core/{ => shared}/utils.py (100%) diff --git a/ergon_core/ergon_core/core/__init__.py b/ergon_core/ergon_core/core/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/ergon_core/ergon_core/core/__init__.py @@ -0,0 +1 @@ + diff --git a/ergon_core/ergon_core/core/shared/__init__.py b/ergon_core/ergon_core/core/shared/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ergon_core/ergon_core/core/json_types.py b/ergon_core/ergon_core/core/shared/json_types.py similarity index 100% rename from ergon_core/ergon_core/core/json_types.py rename to ergon_core/ergon_core/core/shared/json_types.py diff --git a/ergon_core/ergon_core/core/settings.py b/ergon_core/ergon_core/core/shared/settings.py similarity index 97% rename from ergon_core/ergon_core/core/settings.py rename to ergon_core/ergon_core/core/shared/settings.py index e2643d71..d001af23 100644 --- a/ergon_core/ergon_core/core/settings.py +++ b/ergon_core/ergon_core/core/shared/settings.py @@ -63,7 +63,7 @@ class Settings(BaseSettings): @property def data_dir(self) -> Path: - return Path(__file__).parent.parent / "data" + return Path(__file__).parent.parent.parent / "data" @property def runs_dir(self) -> Path: diff --git a/ergon_core/ergon_core/core/utils.py b/ergon_core/ergon_core/core/shared/utils.py similarity index 100% rename from ergon_core/ergon_core/core/utils.py rename to ergon_core/ergon_core/core/shared/utils.py From 34b84ce96bb2137ea9e6183e6709e71307d49f07 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:44 +0100 Subject: [PATCH 48/66] refactor: move experiment domain models into domain package Made-with: Cursor --- ergon_core/ergon_core/api/experiment.py | 181 ------------------ ergon_core/ergon_core/core/domain/__init__.py | 0 .../core/domain/experiments/__init__.py | 7 + .../core/domain/experiments/experiment.py | 70 +++++++ .../core/domain/experiments/handles.py | 23 +++ .../core/domain/experiments/validation.py | 124 ++++++++++++ .../core/domain/experiments/worker_spec.py | 26 +++ 7 files changed, 250 insertions(+), 181 deletions(-) delete mode 100644 ergon_core/ergon_core/api/experiment.py create mode 100644 ergon_core/ergon_core/core/domain/__init__.py create mode 100644 ergon_core/ergon_core/core/domain/experiments/__init__.py create mode 100644 ergon_core/ergon_core/core/domain/experiments/experiment.py create mode 100644 ergon_core/ergon_core/core/domain/experiments/handles.py create mode 100644 ergon_core/ergon_core/core/domain/experiments/validation.py create mode 100644 ergon_core/ergon_core/core/domain/experiments/worker_spec.py diff --git a/ergon_core/ergon_core/api/experiment.py b/ergon_core/ergon_core/api/experiment.py deleted file mode 100644 index 21ab84ec..00000000 --- a/ergon_core/ergon_core/api/experiment.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Public experiment composition root.""" - -from collections.abc import Mapping, Sequence -from typing import Any - -from ergon_core.api.benchmark import Benchmark -from ergon_core.api.evaluator import Evaluator -from ergon_core.api.handles import PersistedExperimentDefinition -from ergon_core.api.worker_spec import WorkerSpec - - -class Experiment: - """Composition root binding a benchmark, worker specs, evaluators, and assignments. - - This is the main object users build and hand to ``persist()``. - - reason: RFC 2026-04-22 §1 — workers are described here as ``WorkerSpec`` - (config-time descriptor), not as live ``Worker`` instances. The - registry factory is invoked exactly once per task at execute time with - the real ``task_id`` / ``sandbox_id``. Holding a ``Worker`` here would - force either sentinel identity fields or constructing the same worker - twice. - """ - - def __init__( - self, - *, - benchmark: Benchmark, - workers: Mapping[str, WorkerSpec], - evaluators: Mapping[str, Evaluator] | None = None, - assignments: Mapping[str, str | Sequence[str]] | None = None, - metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] - ) -> None: - self.benchmark = benchmark - self.workers: dict[str, WorkerSpec] = dict(workers) - self.evaluators: dict[str, Evaluator] = dict(evaluators or {}) - self.assignments: dict[str, str | list[str]] | None = ( - _normalise_assignments(assignments) if assignments is not None else None - ) - self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] - self._persisted: PersistedExperimentDefinition | None = None - - @classmethod - def from_single_worker( - cls, - *, - benchmark: Benchmark, - worker: WorkerSpec, - evaluators: Mapping[str, Evaluator] | None = None, - metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] - ) -> "Experiment": - """Convenience constructor for the common single-worker case.""" - binding_key = worker.name - return cls( - benchmark=benchmark, - workers={binding_key: worker}, - evaluators=evaluators, - assignments=None, - metadata=metadata, - ) - - # ------------------------------------------------------------------ - # Validation - # ------------------------------------------------------------------ - - def validate(self) -> None: - """Cheap composition validation of the full experiment object graph. - - Checks: - - benchmark validates - - every worker validates - - every evaluator validates - - required evaluator slots are filled - - no duplicate task slugs within an instance - - parent_task_slug and dependency_task_slugs reference valid tasks - - assignment worker keys and task slugs reference valid objects - """ - self.benchmark.validate() - for spec in self.workers.values(): - spec.validate_spec() - for evaluator in self.evaluators.values(): - evaluator.validate() - - if self.evaluators: - required_slots = set(self.benchmark.evaluator_requirements()) - missing_slots = required_slots - set(self.evaluators) - if missing_slots: - missing = ", ".join(sorted(missing_slots)) - raise ValueError(f"Missing required evaluator bindings: {missing}") - - instances = self.benchmark.build_instances() - all_task_slugs_by_instance: dict[str, set[str]] = {} - - for instance_key, tasks in instances.items(): - task_slugs: set[str] = set() - for task in tasks: - if task.instance_key != instance_key: - raise ValueError( - f"Task {task.task_slug!r} declares instance_key " - f"{task.instance_key!r} but belongs to instance {instance_key!r}" - ) - if task.task_slug in task_slugs: - raise ValueError( - f"Duplicate task_slug {task.task_slug!r} in instance {instance_key!r}" - ) - task_slugs.add(task.task_slug) - - for task in tasks: - if task.parent_task_slug is not None and task.parent_task_slug not in task_slugs: - raise ValueError( - f"Unknown parent_task_slug {task.parent_task_slug!r} " - f"in instance {instance_key!r}" - ) - for dep_slug in task.dependency_task_slugs: - if dep_slug not in task_slugs: - raise ValueError( - f"Unknown dependency_task_slug {dep_slug!r} for task " - f"{task.task_slug!r} in instance {instance_key!r}" - ) - for eval_key in task.evaluator_binding_keys: - if eval_key not in self.evaluators: - raise ValueError( - f"Task {task.task_slug!r} references undeclared evaluator " - f"binding key {eval_key!r}" - ) - - all_task_slugs_by_instance[instance_key] = task_slugs - - if self.assignments is not None: - all_task_slugs_flat = { - ts for slugs in all_task_slugs_by_instance.values() for ts in slugs - } - for worker_key, task_ref in self.assignments.items(): - if worker_key not in self.workers: - raise ValueError(f"Assignment references unknown worker key {worker_key!r}") - task_slugs_list = [task_ref] if isinstance(task_ref, str) else task_ref - for ts in task_slugs_list: - if ts not in all_task_slugs_flat: - raise ValueError( - f"Assignment references unknown task_slug {ts!r} " - f"for worker {worker_key!r}" - ) - - # ------------------------------------------------------------------ - # Persistence - # ------------------------------------------------------------------ - - def persist(self) -> PersistedExperimentDefinition: - """Validate, materialise instances, and write immutable definition rows. - - Returns a rich ``PersistedExperimentDefinition`` handle. - """ - # Deferred: api/ should not depend on core/ at module level. - # These are the only api->core imports. Extracting to a composition - # layer is flagged for v2. - from ergon_core.core.runtime.services.experiment_persistence_service import ( - ExperimentPersistenceService, - ) - - self.validate() - persisted = ExperimentPersistenceService().persist_definition(self) - self._persisted = persisted - return persisted - - -# ------------------------------------------------------------------ -# Helpers -# ------------------------------------------------------------------ - - -def _normalise_assignments( - raw: Mapping[str, str | Sequence[str]], -) -> dict[str, str | list[str]]: - """Convert immutable mapping values to mutable lists where needed.""" - out: dict[str, str | list[str]] = {} - for key, value in raw.items(): - if isinstance(value, str): - out[key] = value - else: - out[key] = list(value) - return out diff --git a/ergon_core/ergon_core/core/domain/__init__.py b/ergon_core/ergon_core/core/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ergon_core/ergon_core/core/domain/experiments/__init__.py b/ergon_core/ergon_core/core/domain/experiments/__init__.py new file mode 100644 index 00000000..f292da84 --- /dev/null +++ b/ergon_core/ergon_core/core/domain/experiments/__init__.py @@ -0,0 +1,7 @@ +"""Core-owned experiment composition types.""" + +from ergon_core.core.domain.experiments.experiment import Experiment +from ergon_core.core.domain.experiments.handles import DefinitionHandle +from ergon_core.core.domain.experiments.worker_spec import WorkerSpec + +__all__ = ["DefinitionHandle", "Experiment", "WorkerSpec"] diff --git a/ergon_core/ergon_core/core/domain/experiments/experiment.py b/ergon_core/ergon_core/core/domain/experiments/experiment.py new file mode 100644 index 00000000..8acef137 --- /dev/null +++ b/ergon_core/ergon_core/core/domain/experiments/experiment.py @@ -0,0 +1,70 @@ +"""Core experiment composition root.""" + +from collections.abc import Mapping, Sequence +from typing import Any + +from ergon_core.api.benchmark import Benchmark +from ergon_core.api.rubric import Evaluator +from ergon_core.core.domain.experiments.handles import DefinitionHandle +from ergon_core.core.domain.experiments.worker_spec import WorkerSpec + + +class Experiment: + """Composition root binding a benchmark, worker specs, evaluators, and assignments.""" + + def __init__( + self, + *, + benchmark: Benchmark, + workers: Mapping[str, WorkerSpec], + evaluators: Mapping[str, Evaluator] | None = None, + assignments: Mapping[str, str | Sequence[str]] | None = None, + metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] + ) -> None: + self.benchmark = benchmark + self.workers: dict[str, WorkerSpec] = dict(workers) + self.evaluators: dict[str, Evaluator] = dict(evaluators or {}) + self.assignments: dict[str, str | list[str]] | None = ( + _normalise_assignments(assignments) if assignments is not None else None + ) + self.metadata: dict[str, Any] = dict(metadata or {}) # slopcop: ignore[no-typing-any] + self._persisted: DefinitionHandle | None = None + + @classmethod + def from_single_worker( + cls, + *, + benchmark: Benchmark, + worker: WorkerSpec, + evaluators: Mapping[str, Evaluator] | None = None, + metadata: Mapping[str, Any] | None = None, # slopcop: ignore[no-typing-any] + ) -> "Experiment": + """Convenience constructor for the common single-worker case.""" + binding_key = worker.name + return cls( + benchmark=benchmark, + workers={binding_key: worker}, + evaluators=evaluators, + assignments=None, + metadata=metadata, + ) + + def validate(self) -> None: + """Cheap composition validation of the full experiment object graph.""" + from ergon_core.core.domain.experiments.validation import ( + ExperimentValidationService, + ) + + ExperimentValidationService().validate(self) + +def _normalise_assignments( + raw: Mapping[str, str | Sequence[str]], +) -> dict[str, str | list[str]]: + """Convert immutable mapping values to mutable lists where needed.""" + out: dict[str, str | list[str]] = {} + for key, value in raw.items(): + if isinstance(value, str): + out[key] = value + else: + out[key] = list(value) + return out diff --git a/ergon_core/ergon_core/core/domain/experiments/handles.py b/ergon_core/ergon_core/core/domain/experiments/handles.py new file mode 100644 index 00000000..2bb55ef4 --- /dev/null +++ b/ergon_core/ergon_core/core/domain/experiments/handles.py @@ -0,0 +1,23 @@ +"""Core lifecycle handles for persisted workflow definitions.""" + +from datetime import datetime +from typing import Any +from uuid import UUID + +from ergon_core.core.shared.utils import utcnow +from pydantic import BaseModel, Field + + +class DefinitionHandle(BaseModel): + """Rich handle returned after an experiment definition is persisted.""" + + model_config = {"frozen": True} + + definition_id: UUID + benchmark_type: str + worker_bindings: dict[str, str] = Field(default_factory=dict) + evaluator_bindings: dict[str, str] = Field(default_factory=dict) + instance_count: int = 0 + task_count: int = 0 + created_at: datetime = Field(default_factory=utcnow) + metadata: dict[str, Any] = Field(default_factory=dict) # slopcop: ignore[no-typing-any] diff --git a/ergon_core/ergon_core/core/domain/experiments/validation.py b/ergon_core/ergon_core/core/domain/experiments/validation.py new file mode 100644 index 00000000..69e62673 --- /dev/null +++ b/ergon_core/ergon_core/core/domain/experiments/validation.py @@ -0,0 +1,124 @@ +"""Experiment composition validation service.""" + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING + +from ergon_core.api.benchmark import Benchmark, Task +from ergon_core.api.rubric import Evaluator +from ergon_core.core.domain.experiments.worker_spec import WorkerSpec + +if TYPE_CHECKING: + from ergon_core.core.domain.experiments import Experiment + + +class ExperimentValidationService: + """Validate experiment composition before persistence or launch.""" + + def validate(self, experiment: "Experiment") -> None: + experiment.benchmark.validate() + for spec in experiment.workers.values(): + spec.validate_spec() + for evaluator in experiment.evaluators.values(): + evaluator.validate() + + _validate_required_evaluators(experiment.benchmark, experiment.evaluators) + task_slugs_by_instance = _validate_instances( + experiment.benchmark.build_instances(), + set(experiment.evaluators), + ) + _validate_assignments(experiment.assignments, experiment.workers, task_slugs_by_instance) + + +def _validate_required_evaluators( + benchmark: Benchmark, + evaluators: Mapping[str, Evaluator], +) -> None: + if not evaluators: + return + required_slots = set(benchmark.evaluator_requirements()) + missing_slots = required_slots - set(evaluators) + if missing_slots: + missing = ", ".join(sorted(missing_slots)) + raise ValueError(f"Missing required evaluator bindings: {missing}") + + +def _validate_instances( + instances: Mapping[str, Sequence[Task]], + evaluator_keys: set[str], +) -> dict[str, set[str]]: + all_task_slugs_by_instance: dict[str, set[str]] = {} + for instance_key, tasks in instances.items(): + task_slugs = _collect_task_slugs(instance_key, tasks) + _validate_task_links(instance_key, tasks, task_slugs, evaluator_keys) + all_task_slugs_by_instance[instance_key] = task_slugs + return all_task_slugs_by_instance + + +def _collect_task_slugs(instance_key: str, tasks: Sequence[Task]) -> set[str]: + task_slugs: set[str] = set() + for task in tasks: + if task.instance_key != instance_key: + raise ValueError( + f"Task {task.task_slug!r} declares instance_key " + f"{task.instance_key!r} but belongs to instance {instance_key!r}" + ) + if task.task_slug in task_slugs: + raise ValueError(f"Duplicate task_slug {task.task_slug!r} in instance {instance_key!r}") + task_slugs.add(task.task_slug) + return task_slugs + + +def _validate_task_links( + instance_key: str, + tasks: Sequence[Task], + task_slugs: set[str], + evaluator_keys: set[str], +) -> None: + for task in tasks: + _validate_parent_task(instance_key, task, task_slugs) + _validate_dependency_tasks(instance_key, task, task_slugs) + _validate_task_evaluators(task, evaluator_keys) + + +def _validate_parent_task(instance_key: str, task: Task, task_slugs: set[str]) -> None: + if task.parent_task_slug is not None and task.parent_task_slug not in task_slugs: + raise ValueError( + f"Unknown parent_task_slug {task.parent_task_slug!r} in instance {instance_key!r}" + ) + + +def _validate_dependency_tasks(instance_key: str, task: Task, task_slugs: set[str]) -> None: + for dep_slug in task.dependency_task_slugs: + if dep_slug not in task_slugs: + raise ValueError( + f"Unknown dependency_task_slug {dep_slug!r} for task " + f"{task.task_slug!r} in instance {instance_key!r}" + ) + + +def _validate_task_evaluators(task: Task, evaluator_keys: set[str]) -> None: + for eval_key in task.evaluator_binding_keys: + if eval_key not in evaluator_keys: + raise ValueError( + f"Task {task.task_slug!r} references undeclared evaluator binding key {eval_key!r}" + ) + + +def _validate_assignments( + assignments: Mapping[str, str | Sequence[str]] | None, + workers: Mapping[str, WorkerSpec], + task_slugs_by_instance: Mapping[str, set[str]], +) -> None: + if assignments is None: + return + all_task_slugs_flat = {ts for slugs in task_slugs_by_instance.values() for ts in slugs} + for worker_key, task_ref in assignments.items(): + if worker_key not in workers: + raise ValueError(f"Assignment references unknown worker key {worker_key!r}") + task_slugs_list = [task_ref] if isinstance(task_ref, str) else task_ref + for task_slug in task_slugs_list: + if task_slug not in all_task_slugs_flat: + raise ValueError( + f"Assignment references unknown task_slug {task_slug!r} " + f"for worker {worker_key!r}" + ) diff --git a/ergon_core/ergon_core/core/domain/experiments/worker_spec.py b/ergon_core/ergon_core/core/domain/experiments/worker_spec.py new file mode 100644 index 00000000..a810e614 --- /dev/null +++ b/ergon_core/ergon_core/core/domain/experiments/worker_spec.py @@ -0,0 +1,26 @@ +"""Config-time descriptor for a worker binding.""" + +from ergon_core.api.registry import registry +from pydantic import BaseModel, ConfigDict + + +class WorkerSpec(BaseModel): + """Immutable descriptor for a worker binding in an Experiment.""" + + model_config = ConfigDict(frozen=True) + + worker_slug: str + name: str + model: str + + def validate_spec(self) -> None: + """Check that ``worker_slug`` refers to a known registry entry.""" + if self.worker_slug not in registry.workers: + known = ", ".join(sorted(registry.workers)) or "" + raise ValueError( + f"Unknown worker slug {self.worker_slug!r}; registered workers: {known}" + ) + if not self.name: + raise ValueError("WorkerSpec.name must be a non-empty string") + if not self.model: + raise ValueError("WorkerSpec.model must be a non-empty string") From 94b3a2963cbff77eed8155ad04710da69cf06c85 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:44 +0100 Subject: [PATCH 49/66] refactor: move generation context models into domain package Made-with: Cursor --- .../core/domain/generation/__init__.py | 0 .../generation/context_parts.py} | 2 +- .../core/providers/generation/__init__.py | 8 -- .../providers/generation/model_resolution.py | 76 ------------- .../providers/generation/openai_compatible.py | 100 ------------------ .../core/providers/generation/openrouter.py | 55 ---------- .../generation/pydantic_ai_format.py | 44 -------- .../core/providers/generation/types.py | 17 --- 8 files changed, 1 insertion(+), 301 deletions(-) create mode 100644 ergon_core/ergon_core/core/domain/generation/__init__.py rename ergon_core/ergon_core/core/{generation.py => domain/generation/context_parts.py} (98%) delete mode 100644 ergon_core/ergon_core/core/providers/generation/__init__.py delete mode 100644 ergon_core/ergon_core/core/providers/generation/model_resolution.py delete mode 100644 ergon_core/ergon_core/core/providers/generation/openai_compatible.py delete mode 100644 ergon_core/ergon_core/core/providers/generation/openrouter.py delete mode 100644 ergon_core/ergon_core/core/providers/generation/pydantic_ai_format.py delete mode 100644 ergon_core/ergon_core/core/providers/generation/types.py diff --git a/ergon_core/ergon_core/core/domain/generation/__init__.py b/ergon_core/ergon_core/core/domain/generation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ergon_core/ergon_core/core/generation.py b/ergon_core/ergon_core/core/domain/generation/context_parts.py similarity index 98% rename from ergon_core/ergon_core/core/generation.py rename to ergon_core/ergon_core/core/domain/generation/context_parts.py index 0178d686..51ad12c4 100644 --- a/ergon_core/ergon_core/core/generation.py +++ b/ergon_core/ergon_core/core/domain/generation/context_parts.py @@ -8,7 +8,7 @@ from datetime import datetime from typing import Annotated, Any, Literal -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject from pydantic import BaseModel, Field diff --git a/ergon_core/ergon_core/core/providers/generation/__init__.py b/ergon_core/ergon_core/core/providers/generation/__init__.py deleted file mode 100644 index 5a166577..00000000 --- a/ergon_core/ergon_core/core/providers/generation/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Generation provider helpers for model-specific integrations.""" - -from ergon_core.core.providers.generation.model_resolution import ( - ResolvedModel, - resolve_model_target, -) - -__all__ = ["ResolvedModel", "resolve_model_target"] diff --git a/ergon_core/ergon_core/core/providers/generation/model_resolution.py b/ergon_core/ergon_core/core/providers/generation/model_resolution.py deleted file mode 100644 index c3f254f9..00000000 --- a/ergon_core/ergon_core/core/providers/generation/model_resolution.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Prefix-based model target resolution.""" - -import pydantic_ai.models -from pydantic import BaseModel - - -class ResolvedModel(BaseModel): - """A resolved model target with backend metadata. - - Workers pass ``.model`` to ``Agent(model=...)``, read - ``.policy_version`` for provenance metadata, and check - ``.supports_logprobs`` to decide whether to expect per-token - logprob data in the response. - """ - - model_config = {"frozen": True, "arbitrary_types_allowed": True} - - model: pydantic_ai.models.Model | str - policy_version: str | None = None - supports_logprobs: bool = False - - -def resolve_model_target( - model_target: str | None, - *, - model_name: str | None = None, - policy_version: str | None = None, - api_key: str | None = None, -) -> ResolvedModel: - """Resolve a ``model_target`` string to a PydanticAI-compatible model. - - Cloud provider targets (``openai:*``, ``anthropic:*``, ``google:*``) - intentionally resolve to OpenRouter-hosted models. Direct cloud provider - API access is not part of Ergon's model-target grammar. - """ - - target = model_target or "openai:gpt-4o" - prefix = target.split(":", 1)[0] if ":" in target else "" - - if prefix == "vllm": - from ergon_core.core.providers.generation.openai_compatible import ( # slopcop: ignore[guarded-function-import] -- reason: avoid import cycle; provider modules import ResolvedModel - resolve_vllm, - ) - - return resolve_vllm( - target, model_name=model_name, policy_version=policy_version, api_key=api_key - ) - - if prefix == "openai-compatible": - from ergon_core.core.providers.generation.openai_compatible import ( # slopcop: ignore[guarded-function-import] -- reason: avoid import cycle; provider modules import ResolvedModel - resolve_openai_compatible, - ) - - return resolve_openai_compatible( - target, model_name=model_name, policy_version=policy_version, api_key=api_key - ) - - if prefix in {"openai", "anthropic", "google"}: - from ergon_core.core.providers.generation.openrouter import ( # slopcop: ignore[guarded-function-import] -- reason: avoid import cycle; provider modules import ResolvedModel - resolve_cloud_via_openrouter, - ) - - return resolve_cloud_via_openrouter( - target, model_name=model_name, policy_version=policy_version, api_key=api_key - ) - - if prefix == "openrouter": - from ergon_core.core.providers.generation.openrouter import ( # slopcop: ignore[guarded-function-import] -- reason: avoid import cycle; provider modules import ResolvedModel - resolve_openrouter_alias, - ) - - return resolve_openrouter_alias( - target, model_name=model_name, policy_version=policy_version, api_key=api_key - ) - - raise ValueError(f"Unsupported model target: {target!r}") diff --git a/ergon_core/ergon_core/core/providers/generation/openai_compatible.py b/ergon_core/ergon_core/core/providers/generation/openai_compatible.py deleted file mode 100644 index 8a4b8727..00000000 --- a/ergon_core/ergon_core/core/providers/generation/openai_compatible.py +++ /dev/null @@ -1,100 +0,0 @@ -"""OpenAI-compatible endpoint resolution for local and custom model servers.""" - -import json -import logging -import urllib.error -import urllib.request - -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openai import OpenAIProvider - -from ergon_core.core.providers.generation.model_resolution import ResolvedModel - -logger = logging.getLogger(__name__) - - -def resolve_openai_compatible( - target: str, - *, - model_name: str | None = None, - policy_version: str | None = None, - api_key: str | None = None, -) -> ResolvedModel: - """Resolve ``openai-compatible:#`` targets.""" - - base_url, parsed_model_name = _split_endpoint_target( - target, - prefix="openai-compatible:", - require_model_name=True, - ) - resolved_name = model_name or parsed_model_name - if resolved_name is None: - raise ValueError("openai-compatible target requires a model name") - provider = OpenAIProvider(base_url=base_url.rstrip("/"), api_key=api_key or "not-needed") - model = OpenAIChatModel(model_name=resolved_name, provider=provider) - return ResolvedModel(model=model, policy_version=policy_version, supports_logprobs=False) - - -def resolve_vllm( - target: str, - *, - model_name: str | None = None, - policy_version: str | None = None, - api_key: str | None = None, -) -> ResolvedModel: - """Resolve ``vllm:[#]`` targets.""" - - endpoint, parsed_model_name = _split_endpoint_target( - target, - prefix="vllm:", - require_model_name=False, - ) - endpoint = endpoint.rstrip("/") - resolved_name = model_name or parsed_model_name or _discover_model_name(endpoint) - provider = OpenAIProvider(base_url=f"{endpoint}/v1", api_key=api_key or "not-needed") - model = OpenAIChatModel(model_name=resolved_name, provider=provider) - logger.info( - "Resolved vLLM model: endpoint=%s model_name=%s policy_version=%s", - endpoint, - resolved_name, - policy_version, - ) - return ResolvedModel(model=model, policy_version=policy_version, supports_logprobs=True) - - -def _split_endpoint_target( - target: str, - *, - prefix: str, - require_model_name: bool, -) -> tuple[str, str | None]: - body = target.removeprefix(prefix) - endpoint, separator, model_name = body.partition("#") - if not endpoint: - raise ValueError(f"{prefix} target requires a base URL") - if require_model_name and not (separator and model_name): - raise ValueError(f"{prefix}# target requires a model name") - return endpoint, model_name or None - - -def _discover_model_name(endpoint: str) -> str: - """Query ``/v1/models`` to discover the served model name.""" - - url = f"{endpoint}/v1/models" - try: - with urllib.request.urlopen(url, timeout=5) as resp: - body = json.loads(resp.read()) - models = body.get("data", []) - if models: - name = models[0].get("id", "default") - logger.info("Discovered vLLM model name: %s", name) - return name - except ( - urllib.error.HTTPError, - urllib.error.URLError, - TimeoutError, - OSError, - json.JSONDecodeError, - ): - logger.warning("Could not discover vLLM model name from %s, using 'default'", url) - return "default" diff --git a/ergon_core/ergon_core/core/providers/generation/openrouter.py b/ergon_core/ergon_core/core/providers/generation/openrouter.py deleted file mode 100644 index 34f8946f..00000000 --- a/ergon_core/ergon_core/core/providers/generation/openrouter.py +++ /dev/null @@ -1,55 +0,0 @@ -"""OpenRouter-hosted cloud model resolution.""" - -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openrouter import OpenRouterProvider - -from ergon_core.core.providers.generation.model_resolution import ResolvedModel -from ergon_core.core.settings import settings - -CLOUD_PROVIDER_PREFIXES = frozenset({"openai", "anthropic", "google"}) - - -def resolve_cloud_via_openrouter( - target: str, - *, - model_name: str | None = None, - policy_version: str | None = None, - api_key: str | None = None, -) -> ResolvedModel: - """Resolve ``openai:*``, ``anthropic:*``, and ``google:*`` through OpenRouter.""" - - provider_prefix, separator, provider_model_name = target.partition(":") - if not separator or not provider_model_name: - raise ValueError(f"Unsupported model target: {target!r}") - if provider_prefix not in CLOUD_PROVIDER_PREFIXES: - raise ValueError(f"Unsupported cloud provider target: {target!r}") - - openrouter_model_name = model_name or f"{provider_prefix}/{provider_model_name}" - provider = _openrouter_provider(api_key) - model = OpenAIChatModel(model_name=openrouter_model_name, provider=provider) - return ResolvedModel(model=model, policy_version=policy_version, supports_logprobs=False) - - -def resolve_openrouter_alias( - target: str, - *, - model_name: str | None = None, - policy_version: str | None = None, - api_key: str | None = None, -) -> ResolvedModel: - """Resolve legacy ``openrouter:/`` targets through OpenRouter.""" - - provider_model_name = target.removeprefix("openrouter:") - if not provider_model_name: - raise ValueError("openrouter:/ target requires a model name") - - provider = _openrouter_provider(api_key) - model = OpenAIChatModel(model_name=model_name or provider_model_name, provider=provider) - return ResolvedModel(model=model, policy_version=policy_version, supports_logprobs=False) - - -def _openrouter_provider(api_key: str | None) -> OpenRouterProvider: - resolved_api_key = api_key or settings.openrouter_api_key - if resolved_api_key: - return OpenRouterProvider(api_key=resolved_api_key) - return OpenRouterProvider() diff --git a/ergon_core/ergon_core/core/providers/generation/pydantic_ai_format.py b/ergon_core/ergon_core/core/providers/generation/pydantic_ai_format.py deleted file mode 100644 index 0077e71a..00000000 --- a/ergon_core/ergon_core/core/providers/generation/pydantic_ai_format.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Single source of truth for parsing PydanticAI's serialised message format. - -PydanticAI serialises ``ModelResponse`` via ``dataclasses.asdict()`` into:: - - { - "parts": [ - {"part_kind": "text", "content": "..."}, - {"part_kind": "tool-call", "tool_call_id": "...", "tool_name": "...", "args": {...}}, - ], - "provider_details": {"logprobs": [{"token": "...", "logprob": -0.1, ...}]}, - ... - } - -All code that needs to read these dumps should call into this module -rather than re-implementing the parsing. -""" - -from ergon_core.core.json_types import JsonObject -from ergon_core.core.providers.generation.types import TokenLogprob - - -def extract_logprobs( - raw: JsonObject, -) -> list[TokenLogprob] | None: - """Extract per-token logprobs from a PydanticAI response dump. - - PydanticAI stores vLLM logprobs in ``provider_details["logprobs"]``. - Returns None if no logprobs are available (cloud APIs). - """ - details = raw.get("provider_details") - if not isinstance(details, dict): - return None - raw_logprobs = details.get("logprobs") - if not isinstance(raw_logprobs, list) or not raw_logprobs: - return None - return [ - TokenLogprob( - token=entry["token"], - logprob=entry["logprob"], - top_logprobs=entry.get("top_logprobs", []), - ) - for entry in raw_logprobs - if isinstance(entry, dict) and "token" in entry and "logprob" in entry - ] diff --git a/ergon_core/ergon_core/core/providers/generation/types.py b/ergon_core/ergon_core/core/providers/generation/types.py deleted file mode 100644 index cf206095..00000000 --- a/ergon_core/ergon_core/core/providers/generation/types.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Shared generation provider value types.""" - -from pydantic import BaseModel, Field - -type JsonScalar = str | int | float | bool | None -type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue] -type JsonObject = dict[str, JsonValue] - - -class TokenLogprob(BaseModel): - """Per-token log probability from the serving backend.""" - - model_config = {"frozen": True} - - token: str - logprob: float - top_logprobs: list[JsonObject] = Field(default_factory=list) From b6c7c173039fb5a8d2f14882a01a309319a28d5c Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:45 +0100 Subject: [PATCH 50/66] refactor: move experiment application services Made-with: Cursor --- .../core/application/experiments/__init__.py | 3 + .../experiments/definition_writer.py} | 20 +++--- .../experiments/launch.py} | 54 ++++++++------- .../experiments/models.py} | 12 +++- .../application/experiments/repository.py | 30 ++++++++ .../experiments/service.py} | 69 +++++++++++++++---- 6 files changed, 137 insertions(+), 51 deletions(-) create mode 100644 ergon_core/ergon_core/core/application/experiments/__init__.py rename ergon_core/ergon_core/core/{runtime/services/experiment_persistence_service.py => application/experiments/definition_writer.py} (95%) rename ergon_core/ergon_core/core/{runtime/services/experiment_launch_service.py => application/experiments/launch.py} (80%) rename ergon_core/ergon_core/core/{runtime/services/experiment_schemas.py => application/experiments/models.py} (80%) create mode 100644 ergon_core/ergon_core/core/application/experiments/repository.py rename ergon_core/ergon_core/core/{runtime/services/experiment_definition_service.py => application/experiments/service.py} (53%) diff --git a/ergon_core/ergon_core/core/application/experiments/__init__.py b/ergon_core/ergon_core/core/application/experiments/__init__.py new file mode 100644 index 00000000..30e5430d --- /dev/null +++ b/ergon_core/ergon_core/core/application/experiments/__init__.py @@ -0,0 +1,3 @@ +from ergon_core.core.application.experiments.service import ExperimentService + +__all__ = ["ExperimentService"] diff --git a/ergon_core/ergon_core/core/runtime/services/experiment_persistence_service.py b/ergon_core/ergon_core/core/application/experiments/definition_writer.py similarity index 95% rename from ergon_core/ergon_core/core/runtime/services/experiment_persistence_service.py rename to ergon_core/ergon_core/core/application/experiments/definition_writer.py index cd49274a..29575f9b 100644 --- a/ergon_core/ergon_core/core/runtime/services/experiment_persistence_service.py +++ b/ergon_core/ergon_core/core/application/experiments/definition_writer.py @@ -7,9 +7,9 @@ from typing import TYPE_CHECKING from uuid import uuid4 -from ergon_core.api.evaluator import Rubric -from ergon_core.api.handles import PersistedExperimentDefinition -from ergon_core.core.json_types import JsonObject +from ergon_core.api.rubric import Rubric +from ergon_core.core.domain.experiments import DefinitionHandle +from ergon_core.core.shared.json_types import JsonObject from ergon_core.core.persistence.definitions.models import ( ExperimentDefinition, ExperimentDefinitionEvaluator, @@ -21,14 +21,14 @@ ExperimentDefinitionWorker, ) from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.utils import utcnow +from ergon_core.core.shared.utils import utcnow from sqlalchemy.exc import SQLAlchemyError if TYPE_CHECKING: - from ergon_core.api.experiment import Experiment + from ergon_core.core.domain.experiments import Experiment -class ExperimentPersistenceService: +class _ExperimentDefinitionWriter: """Writes immutable definition rows directly from an Experiment. Identity-not-serialization: rows store type slugs + model_target, @@ -37,10 +37,10 @@ class ExperimentPersistenceService: data -- nothing reconstructs from it. """ - def persist_definition( + def persist_definition( # noqa: C901 self, experiment: "Experiment", - ) -> PersistedExperimentDefinition: + ) -> DefinitionHandle: # ---- 1. Validate ------------------------------------------------ experiment.validate() @@ -64,7 +64,7 @@ def persist_definition( # reason: RFC 2026-04-22 §1 — ``Experiment.workers`` now holds # ``WorkerSpec`` descriptors. ``worker_slug`` maps 1:1 to # ``ExperimentDefinitionWorker.worker_type`` (registry key persisted - # verbatim; worker_execute looks it up back through ``WORKERS``). + # verbatim; worker_execute looks it up through the core registry). worker_rows: list[ExperimentDefinitionWorker] = [] worker_bindings: dict[str, str] = {} @@ -252,7 +252,7 @@ def persist_definition( session.close() # ---- 6. Return handle -------------------------------------------- - return PersistedExperimentDefinition( + return DefinitionHandle( definition_id=definition_id, benchmark_type=benchmark_type, worker_bindings=worker_bindings, diff --git a/ergon_core/ergon_core/core/runtime/services/experiment_launch_service.py b/ergon_core/ergon_core/core/application/experiments/launch.py similarity index 80% rename from ergon_core/ergon_core/core/runtime/services/experiment_launch_service.py rename to ergon_core/ergon_core/core/application/experiments/launch.py index cad6ec8b..da496cd9 100644 --- a/ergon_core/ergon_core/core/runtime/services/experiment_launch_service.py +++ b/ergon_core/ergon_core/core/application/experiments/launch.py @@ -5,32 +5,33 @@ import inngest from ergon_core.api.benchmark import Benchmark -from ergon_core.api.evaluator import Evaluator -from ergon_core.api.experiment import Experiment -from ergon_core.api.handles import PersistedExperimentDefinition -from ergon_core.core.json_types import JsonObject -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.api.worker_spec import WorkerSpec +from ergon_core.api.registry import registry +from ergon_core.api.rubric import Evaluator +from ergon_core.core.domain.experiments import Experiment +from ergon_core.core.domain.experiments import DefinitionHandle +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.api.benchmark import Task +from ergon_core.core.domain.experiments import WorkerSpec from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import ExperimentRecord -from ergon_core.core.runtime.events.task_events import WorkflowStartedEvent -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.experiment_schemas import ( +from ergon_core.core.application.events.task_events import WorkflowStartedEvent +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.application.experiments.models import ( ExperimentRunRequest, ExperimentRunResult, RunAssignment, ) -from ergon_core.core.runtime.services.run_service import create_run +from ergon_core.core.application.workflows.runs import create_run from pydantic import BaseModel WorkflowDefinitionFactory = Callable[ [ExperimentRecord, RunAssignment], - PersistedExperimentDefinition, + DefinitionHandle, ] WorkflowStartedEmitter = Callable[[UUID, UUID], Awaitable[None]] -class ExperimentLaunchService: +class _ExperimentRunLauncher: """Materialize runs for a previously defined experiment.""" def __init__( @@ -67,6 +68,8 @@ async def run_experiment(self, request: ExperimentRunRequest) -> ExperimentRunRe worker_team_json=assignment.worker_team, evaluator_slug=assignment.evaluator_slug, model_target=assignment.model_target, + sandbox_slug=assignment.sandbox_slug, + dependency_extras_json={"extras": list(assignment.dependency_extras)}, assignment_json=assignment.metadata, seed=assignment.seed, ) @@ -96,6 +99,8 @@ def _assign_runs(experiment: ExperimentRecord) -> list[RunAssignment]: worker_team=experiment.parsed_default_worker_team(), evaluator_slug=experiment.default_evaluator_slug, model_target=experiment.default_model_target, + sandbox_slug=experiment.sandbox_slug, + dependency_extras=tuple(experiment.parsed_dependency_extras().get("extras", ())), arm_key="default", seed=experiment.seed, metadata={"arm_key": "default"}, @@ -107,7 +112,11 @@ def _assign_runs(experiment: ExperimentRecord) -> list[RunAssignment]: def _persist_single_sample_workflow_definition( experiment: ExperimentRecord, assignment: RunAssignment, -) -> PersistedExperimentDefinition: +) -> DefinitionHandle: + from ergon_core.core.application.experiments.definition_writer import ( # slopcop: ignore[guarded-function-import] -- reason: keep definition writing behind application launch plumbing + _ExperimentDefinitionWriter, + ) + benchmark_slug = _metadata_str(experiment, "benchmark_slug") or experiment.benchmark_type benchmark = _single_sample_benchmark(benchmark_slug, assignment.instance_key) worker_slug = _primary_worker_slug(assignment.worker_team) @@ -122,7 +131,8 @@ def _persist_single_sample_workflow_definition( worker=worker, evaluators=evaluators, ) - return workflow.persist() + workflow.validate() + return _ExperimentDefinitionWriter().persist_definition(workflow) def _metadata_str(experiment: ExperimentRecord, key: str) -> str | None: @@ -140,20 +150,12 @@ def _primary_worker_slug(worker_team: JsonObject) -> str: def _evaluator_bindings(evaluator_slug: str | None) -> dict[str, Evaluator]: if evaluator_slug is None: return {} - from ergon_builtins.registry import ( # slopcop: ignore[guarded-function-import] -- reason: optional plugin registry; load only when launching experiment runs - EVALUATORS, - ) - - evaluator_cls = EVALUATORS[evaluator_slug] + evaluator_cls = registry.require_evaluator(evaluator_slug) return {"default": evaluator_cls(name="evaluator")} def _single_sample_benchmark(benchmark_slug: str, instance_key: str) -> Benchmark: - from ergon_builtins.registry import ( # slopcop: ignore[guarded-function-import] -- reason: optional plugin registry; load only when launching experiment runs - BENCHMARKS, - ) - - source = BENCHMARKS[benchmark_slug]() + source = registry.require_benchmark(benchmark_slug)() instances = source.build_instances() if instance_key not in instances: raise ValueError( @@ -170,7 +172,7 @@ def __init__( self, source: Benchmark, instance_key: str, - tasks: Sequence[BenchmarkTask[BaseModel]], + tasks: Sequence[Task[BaseModel]], ) -> None: super().__init__( name=source.name, @@ -181,7 +183,7 @@ def __init__( self._instance_key = instance_key self._tasks = list(tasks) - def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[BaseModel]]]: + def build_instances(self) -> Mapping[str, Sequence[Task[BaseModel]]]: return {self._instance_key: self._tasks} def evaluator_requirements(self) -> Sequence[str]: diff --git a/ergon_core/ergon_core/core/runtime/services/experiment_schemas.py b/ergon_core/ergon_core/core/application/experiments/models.py similarity index 80% rename from ergon_core/ergon_core/core/runtime/services/experiment_schemas.py rename to ergon_core/ergon_core/core/application/experiments/models.py index 4d60c636..f51e9125 100644 --- a/ergon_core/ergon_core/core/runtime/services/experiment_schemas.py +++ b/ergon_core/ergon_core/core/application/experiments/models.py @@ -3,7 +3,7 @@ from typing import Self from uuid import UUID -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject from pydantic import BaseModel, Field, model_validator @@ -16,6 +16,8 @@ class ExperimentDefineRequest(BaseModel): default_model_target: str | None = None default_worker_team: JsonObject = Field(default_factory=dict) default_evaluator_slug: str | None = None + sandbox_slug: str | None = None + dependency_extras: tuple[str, ...] = () design: JsonObject = Field(default_factory=dict) seed: int | None = None metadata: JsonObject = Field(default_factory=dict) @@ -35,6 +37,12 @@ def validate_define_request(self) -> Self: raise ValueError( "Experiment definition requires default_worker_team + default_model_target" ) + if not self.default_evaluator_slug: + raise ValueError("Experiment definition requires default_evaluator_slug") + if not self.sandbox_slug: + raise ValueError("Experiment definition requires sandbox_slug") + if not self.dependency_extras: + raise ValueError("Experiment definition requires dependency_extras") return self @@ -64,6 +72,8 @@ class RunAssignment(BaseModel): worker_team: JsonObject evaluator_slug: str | None = None model_target: str | None = None + sandbox_slug: str | None = None + dependency_extras: tuple[str, ...] = () arm_key: str | None = None seed: int | None = None metadata: JsonObject = Field(default_factory=dict) diff --git a/ergon_core/ergon_core/core/application/experiments/repository.py b/ergon_core/ergon_core/core/application/experiments/repository.py new file mode 100644 index 00000000..df34809c --- /dev/null +++ b/ergon_core/ergon_core/core/application/experiments/repository.py @@ -0,0 +1,30 @@ +"""Definition-domain read helpers.""" + +from uuid import UUID + +from ergon_core.core.persistence.definitions.models import ( + ExperimentDefinition, + ExperimentDefinitionInstance, + ExperimentDefinitionTask, +) +from sqlmodel import Session + + +class DefinitionRepository: + """Domain reads over experiment definition rows.""" + + def get(self, session: Session, definition_id: UUID) -> ExperimentDefinition | None: + return session.get(ExperimentDefinition, definition_id) + + def task_with_instance( + self, + session: Session, + task_id: UUID, + ) -> tuple[ExperimentDefinitionTask, ExperimentDefinitionInstance]: + task = session.get(ExperimentDefinitionTask, task_id) + if task is None: + raise ValueError(f"ExperimentDefinitionTask {task_id} not found") + instance = session.get(ExperimentDefinitionInstance, task.instance_id) + if instance is None: + raise ValueError(f"ExperimentDefinitionInstance {task.instance_id} not found") + return task, instance diff --git a/ergon_core/ergon_core/core/runtime/services/experiment_definition_service.py b/ergon_core/ergon_core/core/application/experiments/service.py similarity index 53% rename from ergon_core/ergon_core/core/runtime/services/experiment_definition_service.py rename to ergon_core/ergon_core/core/application/experiments/service.py index 66f24b96..c333e513 100644 --- a/ergon_core/ergon_core/core/runtime/services/experiment_definition_service.py +++ b/ergon_core/ergon_core/core/application/experiments/service.py @@ -1,25 +1,49 @@ -"""Experiment definition service.""" +"""Single front-door service for experiment definition, persistence, and launch.""" -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from inspect import Parameter, signature +from typing import TYPE_CHECKING +from uuid import UUID from ergon_core.api.benchmark import Benchmark -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.benchmark import Task +from ergon_core.api.registry import registry +from ergon_core.core.domain.experiments import DefinitionHandle from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import ExperimentRecord -from ergon_core.core.runtime.services.experiment_schemas import ( +from ergon_core.core.application.experiments.models import ( ExperimentDefineRequest, ExperimentDefineResult, + ExperimentRunRequest, + ExperimentRunResult, + RunAssignment, ) -from ergon_core.core.utils import utcnow +from ergon_core.core.shared.utils import utcnow from pydantic import BaseModel +if TYPE_CHECKING: + from ergon_core.core.domain.experiments import Experiment -class ExperimentDefinitionService: - """Create experiment records without launching runs.""" +WorkflowDefinitionFactory = Callable[ + [ExperimentRecord, RunAssignment], + DefinitionHandle, +] +WorkflowStartedEmitter = Callable[[UUID, UUID], Awaitable[None]] - def __init__(self, *, benchmarks: Mapping[str, Callable[..., Benchmark]] | None = None) -> None: + +class ExperimentService: + """Define persisted experiments, write immutable definitions, and launch runs.""" + + def __init__( + self, + *, + benchmarks: Mapping[str, Callable[..., Benchmark]] | None = None, + workflow_definition_factory: WorkflowDefinitionFactory | None = None, + emit_workflow_started: WorkflowStartedEmitter | None = None, + ) -> None: self._benchmarks = benchmarks + self._workflow_definition_factory = workflow_definition_factory + self._emit_workflow_started = emit_workflow_started def define_benchmark_experiment( self, request: ExperimentDefineRequest @@ -39,6 +63,8 @@ def define_benchmark_experiment( default_worker_team_json=request.default_worker_team, default_evaluator_slug=request.default_evaluator_slug, default_model_target=request.default_model_target, + sandbox_slug=request.sandbox_slug, + dependency_extras_json={"extras": list(request.dependency_extras)}, design_json=request.design, seed=request.seed, metadata_json={ @@ -60,13 +86,28 @@ def define_benchmark_experiment( selected_samples=selected_samples, ) + def persist_definition(self, experiment: "Experiment") -> DefinitionHandle: + """Persist an authored experiment as immutable workflow definition rows.""" + from ergon_core.core.application.experiments.definition_writer import ( # slopcop: ignore[guarded-function-import] -- reason: keep heavy definition writer private to the lifecycle service + _ExperimentDefinitionWriter, + ) + + return _ExperimentDefinitionWriter().persist_definition(experiment) + + async def run_experiment(self, request: ExperimentRunRequest) -> ExperimentRunResult: + """Materialize runs for a previously defined experiment.""" + from ergon_core.core.application.experiments.launch import ( # slopcop: ignore[guarded-function-import] -- reason: launch helper is private runtime plumbing behind this front door + _ExperimentRunLauncher, + ) + + return await _ExperimentRunLauncher( + workflow_definition_factory=self._workflow_definition_factory, + emit_workflow_started=self._emit_workflow_started, + ).run_experiment(request) + def _benchmark_cls(self, benchmark_slug: str) -> Callable[..., Benchmark]: if self._benchmarks is None: - from ergon_builtins.registry import ( # slopcop: ignore[guarded-function-import] -- reason: optional plugin registry; load only when defining benchmark experiments - BENCHMARKS, - ) - - self._benchmarks = BENCHMARKS + self._benchmarks = registry.benchmarks return self._benchmarks[benchmark_slug] @@ -81,7 +122,7 @@ def _construct_benchmark(cls: Callable[..., Benchmark], *, limit: int | None) -> def _select_samples( - instances: Mapping[str, Sequence[BenchmarkTask[BaseModel]]], + instances: Mapping[str, Sequence[Task[BaseModel]]], request: ExperimentDefineRequest, ) -> list[str]: if request.sample_ids is not None: From 85dbd7939ec021edf8d8039fd275510b5335ce87 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:45 +0100 Subject: [PATCH 51/66] refactor: move task and graph application services Made-with: Cursor --- .../graph}/__init__.py | 0 .../core/application/graph/errors.py | 57 ++++++ .../graph/lookup.py} | 0 .../graph/models.py} | 2 +- .../graph}/propagation.py | 114 ++--------- .../graph/repository.py} | 6 +- .../core/application/graph/traversal.py | 57 ++++++ .../core/application/tasks/__init__.py | 5 + .../tasks/cleanup.py} | 10 +- .../core/application/tasks/errors.py | 94 +++++++++ .../tasks/execution.py} | 54 ++--- .../tasks/inspection.py} | 21 +- .../tasks/management.py} | 155 +++++++++------ .../tasks/models.py} | 44 ++++- .../core/application/tasks/repository.py | 111 +++++++++++ .../core/application/tasks/service.py | 10 + .../runtime/services/orchestration_dto.py | 164 ---------------- .../services/subtask_blocking_service.py | 72 ------- .../services/subtask_cancellation_dto.py | 15 -- .../services/subtask_cancellation_service.py | 125 ------------ .../core/runtime/services/task_cleanup_dto.py | 18 -- .../runtime/services/task_inspection_dto.py | 19 -- .../services/task_propagation_service.py | 184 ------------------ 23 files changed, 524 insertions(+), 813 deletions(-) rename ergon_core/ergon_core/core/{runtime/execution => application/graph}/__init__.py (100%) create mode 100644 ergon_core/ergon_core/core/application/graph/errors.py rename ergon_core/ergon_core/core/{runtime/services/graph_lookup.py => application/graph/lookup.py} (100%) rename ergon_core/ergon_core/core/{runtime/services/graph_dto.py => application/graph/models.py} (99%) rename ergon_core/ergon_core/core/{runtime/execution => application/graph}/propagation.py (64%) rename ergon_core/ergon_core/core/{runtime/services/graph_repository.py => application/graph/repository.py} (99%) create mode 100644 ergon_core/ergon_core/core/application/graph/traversal.py create mode 100644 ergon_core/ergon_core/core/application/tasks/__init__.py rename ergon_core/ergon_core/core/{runtime/services/task_cleanup_service.py => application/tasks/cleanup.py} (85%) create mode 100644 ergon_core/ergon_core/core/application/tasks/errors.py rename ergon_core/ergon_core/core/{runtime/services/task_execution_service.py => application/tasks/execution.py} (90%) rename ergon_core/ergon_core/core/{runtime/services/task_inspection_service.py => application/tasks/inspection.py} (84%) rename ergon_core/ergon_core/core/{runtime/services/task_management_service.py => application/tasks/management.py} (86%) rename ergon_core/ergon_core/core/{runtime/services/task_management_dto.py => application/tasks/models.py} (79%) create mode 100644 ergon_core/ergon_core/core/application/tasks/repository.py create mode 100644 ergon_core/ergon_core/core/application/tasks/service.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/orchestration_dto.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/subtask_blocking_service.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/subtask_cancellation_dto.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/task_cleanup_dto.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/task_propagation_service.py diff --git a/ergon_core/ergon_core/core/runtime/execution/__init__.py b/ergon_core/ergon_core/core/application/graph/__init__.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/execution/__init__.py rename to ergon_core/ergon_core/core/application/graph/__init__.py diff --git a/ergon_core/ergon_core/core/application/graph/errors.py b/ergon_core/ergon_core/core/application/graph/errors.py new file mode 100644 index 00000000..e836e44c --- /dev/null +++ b/ergon_core/ergon_core/core/application/graph/errors.py @@ -0,0 +1,57 @@ +"""Graph repository errors. + +Deliberately NOT Inngest-specific (no NonRetriableError subclass). +The graph layer must stay independent of the execution runtime so it +can be reused in training pipelines, replay systems, and test harnesses +that don't run inside Inngest. The Inngest layer wraps these into +NonRetriableError at the function boundary if needed. +""" + +import logging +from uuid import UUID + +logger = logging.getLogger("ergon.graph") + + +class GraphError(Exception): + """Base for all graph repository errors.""" + + def __init__(self, message: str, **context: object) -> None: + ctx_str = " ".join(f"{k}={v}" for k, v in context.items()) if context else "" + full = f"{message} {ctx_str}".strip() + logger.error("[%s] %s", type(self).__name__, full) + super().__init__(full) + + +class CycleError(GraphError): + """Adding the proposed edge would create a cycle.""" + + def __init__(self, source_id: UUID, target_id: UUID, **context: object) -> None: + super().__init__( + f"Edge {source_id} -> {target_id} would create a cycle", + **context, + ) + + +class NodeNotFoundError(GraphError): + """Referenced node does not exist in this run's graph.""" + + def __init__(self, node_id: UUID, **context: object) -> None: + super().__init__(f"Node {node_id} not found", **context) + + +class EdgeNotFoundError(GraphError): + """Referenced edge does not exist in this run's graph.""" + + def __init__(self, edge_id: UUID, **context: object) -> None: + super().__init__(f"Edge {edge_id} not found", **context) + + +class DanglingEdgeError(GraphError): + """Edge references a node that does not exist.""" + + def __init__(self, edge_id: UUID, missing_node_id: UUID, **context: object) -> None: + super().__init__( + f"Edge {edge_id} references missing node {missing_node_id}", + **context, + ) diff --git a/ergon_core/ergon_core/core/runtime/services/graph_lookup.py b/ergon_core/ergon_core/core/application/graph/lookup.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/services/graph_lookup.py rename to ergon_core/ergon_core/core/application/graph/lookup.py diff --git a/ergon_core/ergon_core/core/runtime/services/graph_dto.py b/ergon_core/ergon_core/core/application/graph/models.py similarity index 99% rename from ergon_core/ergon_core/core/runtime/services/graph_dto.py rename to ergon_core/ergon_core/core/application/graph/models.py index bd8d9240..7c95ab3d 100644 --- a/ergon_core/ergon_core/core/runtime/services/graph_dto.py +++ b/ergon_core/ergon_core/core/application/graph/models.py @@ -13,7 +13,7 @@ from uuid import UUID from ergon_core.core.persistence.graph.status_conventions import NodeStatus -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject from ergon_core.core.persistence.graph.models import GraphTargetType, MutationType from ergon_core.core.persistence.shared.types import ( DefinitionId, diff --git a/ergon_core/ergon_core/core/runtime/execution/propagation.py b/ergon_core/ergon_core/core/application/graph/propagation.py similarity index 64% rename from ergon_core/ergon_core/core/runtime/execution/propagation.py rename to ergon_core/ergon_core/core/application/graph/propagation.py index ba7f3745..2d1146f6 100644 --- a/ergon_core/ergon_core/core/runtime/execution/propagation.py +++ b/ergon_core/ergon_core/core/application/graph/propagation.py @@ -1,34 +1,27 @@ -"""Pure DAG state functions for task propagation. +"""Workflow propagation service helpers. All state is stored in the graph layer (RunGraphNode, RunGraphEdge, RunGraphMutation). The graph mutation WAL is the single source of truth for DAG execution state. - -RunTaskStateEvent is no longer written or read by this module. """ from uuid import UUID -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject from ergon_core.core.persistence.definitions.models import ( ExperimentDefinitionTask, ExperimentDefinitionTaskDependency, ) from ergon_core.core.persistence.graph import status_conventions as graph_status from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_lookup import GraphNodeLookup -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository +from ergon_core.core.application.graph.models import MutationMeta +from ergon_core.core.application.graph.lookup import GraphNodeLookup +from ergon_core.core.application.graph.repository import WorkflowGraphRepository from sqlmodel import Session, select _PROPAGATION_META = MutationMeta(actor="system:propagation") -# --------------------------------------------------------------------------- -# Write helpers — all writes go through the graph repo -# --------------------------------------------------------------------------- - - async def _update_task_status( session: Session, run_id: UUID, @@ -112,11 +105,6 @@ async def mark_task_failed( ) -# --------------------------------------------------------------------------- -# Read helpers — all reads go through RunGraphNode -# --------------------------------------------------------------------------- - - async def get_initial_ready_tasks( session: Session, run_id: UUID, @@ -125,7 +113,7 @@ async def get_initial_ready_tasks( graph_repo: WorkflowGraphRepository, graph_lookup: GraphNodeLookup, ) -> list[UUID]: - """Return task IDs that have zero dependencies (root tasks).""" + """Return task IDs that have zero dependencies.""" all_tasks_stmt = select(ExperimentDefinitionTask.id).where( ExperimentDefinitionTask.experiment_definition_id == definition_id, ) @@ -138,11 +126,11 @@ async def get_initial_ready_tasks( ready_ids = list(all_task_ids - tasks_with_deps) - for tid in ready_ids: + for task_id in ready_ids: await mark_task_ready( session, run_id, - tid, + task_id, graph_repo=graph_repo, graph_lookup=graph_lookup, ) @@ -151,11 +139,6 @@ async def get_initial_ready_tasks( return ready_ids -# --------------------------------------------------------------------------- -# Graph-native write helpers (no GraphNodeLookup) -# --------------------------------------------------------------------------- - - async def mark_task_failed_by_node( session: Session, run_id: UUID, @@ -177,11 +160,6 @@ async def mark_task_failed_by_node( ) -# --------------------------------------------------------------------------- -# Graph-native propagation (no GraphNodeLookup, walks RunGraphEdge) -# --------------------------------------------------------------------------- - - async def _block_successors_bfs( session: Session, run_id: UUID, @@ -191,15 +169,7 @@ async def _block_successors_bfs( terminal_status: str, graph_repo: WorkflowGraphRepository, ) -> None: - """BFS: propagate BLOCKED through the entire reachable subgraph. - - Starts from seed_node_ids (direct successors of the failed node). When a - node is BLOCKED, its own outgoing edges are INVALIDATED and its successors - enqueued so BLOCKED propagates transitively (e.g. A→B→C, A fails → both - B and C become BLOCKED in one synchronous pass). - - RUNNING and terminal nodes are skipped. - """ + """Propagate BLOCKED through the reachable downstream graph.""" queue = list(seed_node_ids) while queue: target_id = queue.pop() @@ -251,21 +221,7 @@ async def on_task_completed_or_failed( *, graph_repo: WorkflowGraphRepository, ) -> list[UUID]: - """Handle a node reaching COMPLETED, FAILED, or CANCELLED. - - Returns newly ready node IDs. - - - COMPLETED: outgoing edges become SATISFIED; targets with all dependencies - satisfied transition to PENDING for scheduling. - - FAILED / CANCELLED: outgoing edges become INVALIDATED; reachable successors - transition to BLOCKED unless they are RUNNING or terminal. - - Walks RunGraphEdge so it works for both static and dynamic tasks. - - Precondition: the caller must ensure node_id is already in terminal_status - before calling this function. The node's own status is NOT written here — - only edge statuses and downstream candidate statuses are updated. - """ + """Handle a node reaching COMPLETED, FAILED, or CANCELLED.""" is_success = terminal_status == graph_status.COMPLETED outgoing = list( @@ -287,8 +243,7 @@ async def on_task_completed_or_failed( meta=_PROPAGATION_META, ) - candidate_node_ids = {e.target_node_id for e in outgoing} - + candidate_node_ids = {edge.target_node_id for edge in outgoing} newly_ready: list[UUID] = [] if not is_success: @@ -303,7 +258,6 @@ async def on_task_completed_or_failed( session.commit() return newly_ready - # SUCCESS PATH: source completed — check if candidates can become READY. for candidate_id in candidate_node_ids: candidate_node = session.get(RunGraphNode, candidate_id) if candidate_node is None: @@ -314,20 +268,6 @@ async def on_task_completed_or_failed( ): continue - # Eligibility: - # - PENDING (first activation): normal case. - # - CANCELLED managed subtask (parent_node_id is not None): - # re-activation after the manager or an upstream restart - # invalidated it. Policy: any CANCELLED managed subtask - # re-activates when all deps re-satisfy; if the manager - # explicitly cancelled and doesn't want it re-activated it - # can re-cancel. Keeps propagation logic simple and avoids - # needing a cancel_cause column on the node. - # - CANCELLED static workflow node (parent_node_id is None): - # NOT re-activated — no supervisor to adapt, and the static - # workflow expects terminal nodes to stay terminal. - # - # Everything else (COMPLETED, FAILED, RUNNING, BLOCKED) is skipped. status = candidate_node.status is_managed_subtask = candidate_node.parent_node_id is not None is_pending = status == graph_status.PENDING @@ -345,8 +285,8 @@ async def on_task_completed_or_failed( ).all() ) - source_nodes = [session.get(RunGraphNode, e.source_node_id) for e in incoming] - if all(n is not None and n.status == graph_status.COMPLETED for n in source_nodes): + source_nodes = [session.get(RunGraphNode, edge.source_node_id) for edge in incoming] + if all(node is not None and node.status == graph_status.COMPLETED for node in source_nodes): reason = ( f"all dependencies satisfied after {node_id}" if is_pending @@ -361,9 +301,6 @@ async def on_task_completed_or_failed( actor="system:propagation", reason=reason, ), - # Must be False for the CANCELLED -> PENDING transition; - # CANCELLED is terminal and only_if_not_terminal=True - # would block the re-activation write. only_if_not_terminal=False, ) newly_ready.append(candidate_id) @@ -372,11 +309,6 @@ async def on_task_completed_or_failed( return newly_ready -# --------------------------------------------------------------------------- -# Graph-native terminal-state checks (no definition_id) -# --------------------------------------------------------------------------- - - def is_workflow_complete_v2(session: Session, run_id: UUID) -> bool: """Every node terminal; zero FAILED. CANCELLED is neutral.""" statuses = list( @@ -384,8 +316,8 @@ def is_workflow_complete_v2(session: Session, run_id: UUID) -> bool: ) if not statuses: return True - return all(s in graph_status.TERMINAL_STATUSES for s in statuses) and not any( - s == graph_status.FAILED for s in statuses + return all(status in graph_status.TERMINAL_STATUSES for status in statuses) and not any( + status == graph_status.FAILED for status in statuses ) @@ -393,21 +325,11 @@ def is_workflow_complete_v2(session: Session, run_id: UUID) -> bool: def is_workflow_failed_v2(session: Session, run_id: UUID) -> bool: - """All nodes settled (terminal or BLOCKED) AND at least one FAILED. - - BLOCKED nodes represent predecessor-failed state awaiting operator action. - Once all remaining work is settled — either terminal or BLOCKED with no - PENDING/RUNNING tasks remaining — the run cannot make further autonomous - progress. Treat this as a workflow failure so the RunRecord transitions to - FAILED and criterion evaluation fires. - - BLOCKED nodes are preserved (not CANCELLED) so the operator can examine - them and use operator_unblock / restart_node to resume if desired. - """ + """All nodes settled and at least one FAILED.""" statuses = list( session.exec(select(RunGraphNode.status).where(RunGraphNode.run_id == run_id)).all() ) if not statuses: return False - all_settled = all(s in _SETTLED_STATUSES for s in statuses) - return all_settled and any(s == graph_status.FAILED for s in statuses) + all_settled = all(status in _SETTLED_STATUSES for status in statuses) + return all_settled and any(status == graph_status.FAILED for status in statuses) diff --git a/ergon_core/ergon_core/core/runtime/services/graph_repository.py b/ergon_core/ergon_core/core/application/graph/repository.py similarity index 99% rename from ergon_core/ergon_core/core/runtime/services/graph_repository.py rename to ergon_core/ergon_core/core/application/graph/repository.py index a6da14d1..abf7d4b2 100644 --- a/ergon_core/ergon_core/core/runtime/services/graph_repository.py +++ b/ergon_core/ergon_core/core/application/graph/repository.py @@ -29,13 +29,13 @@ RunGraphNode, ) from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES -from ergon_core.core.runtime.errors.graph_errors import ( +from ergon_core.core.application.graph.errors import ( CycleError, DanglingEdgeError, EdgeNotFoundError, NodeNotFoundError, ) -from ergon_core.core.runtime.services.graph_dto import ( +from ergon_core.core.application.graph.models import ( AnnotationDeletedMutation, AnnotationSetMutation, EdgeAddedMutation, @@ -53,7 +53,7 @@ NodeStatusChangedMutation, WorkflowGraphDto, ) -from ergon_core.core.utils import utcnow +from ergon_core.core.shared.utils import utcnow from pydantic import BaseModel from sqlmodel import Session, col, select diff --git a/ergon_core/ergon_core/core/application/graph/traversal.py b/ergon_core/ergon_core/core/application/graph/traversal.py new file mode 100644 index 00000000..cbbcbcf8 --- /dev/null +++ b/ergon_core/ergon_core/core/application/graph/traversal.py @@ -0,0 +1,57 @@ +"""Containment traversal primitives for runtime graph nodes.""" + +from collections import deque +from uuid import UUID + +from ergon_core.core.persistence.graph.models import RunGraphNode +from sqlmodel import Session, select + + +def descendants( + session: Session, + *, + run_id: UUID, + root_node_id: UUID, + max_depth: int | None = None, +) -> list[RunGraphNode]: + """Return containment descendants under root_node_id in breadth-first order.""" + result: list[RunGraphNode] = [] + queue: deque[tuple[UUID, int]] = deque([(root_node_id, 0)]) + + while queue: + parent_id, depth = queue.popleft() + if max_depth is not None and depth >= max_depth: + continue + + children = list( + session.exec( + select(RunGraphNode).where( + RunGraphNode.run_id == run_id, + RunGraphNode.parent_node_id == parent_id, + ) + ).all() + ) + children.sort(key=lambda node: (node.level, node.task_slug, str(node.id))) + result.extend(children) + queue.extend((child.id, depth + 1) for child in children) + + return result + + +def descendant_ids( + session: Session, + *, + run_id: UUID, + root_node_id: UUID, + max_depth: int | None = None, +) -> set[UUID]: + """Return IDs for containment descendants under root_node_id.""" + return { + node.id + for node in descendants( + session, + run_id=run_id, + root_node_id=root_node_id, + max_depth=max_depth, + ) + } diff --git a/ergon_core/ergon_core/core/application/tasks/__init__.py b/ergon_core/ergon_core/core/application/tasks/__init__.py new file mode 100644 index 00000000..665f5d0b --- /dev/null +++ b/ergon_core/ergon_core/core/application/tasks/__init__.py @@ -0,0 +1,5 @@ +"""Task-domain runtime helpers.""" + +from ergon_core.core.application.tasks.repository import TaskExecutionRepository + +__all__ = ["TaskExecutionRepository"] diff --git a/ergon_core/ergon_core/core/runtime/services/task_cleanup_service.py b/ergon_core/ergon_core/core/application/tasks/cleanup.py similarity index 85% rename from ergon_core/ergon_core/core/runtime/services/task_cleanup_service.py rename to ergon_core/ergon_core/core/application/tasks/cleanup.py index ff96add3..c4fe1b4b 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_cleanup_service.py +++ b/ergon_core/ergon_core/core/application/tasks/cleanup.py @@ -1,11 +1,7 @@ """TaskCleanupService — releases infrastructure for a CANCELLED task execution. -Separated from SubtaskCancellationService because that service operates -on graph nodes (state transitions, fan-out) while this one operates on -execution resources (sandbox, telemetry, context streams). Different -failure characteristics: a failed sandbox teardown should be retried -for this node without re-cancelling siblings. - +Task lifecycle mutation lives in TaskManagementService; this service only +handles per-execution cleanup after cancellation events are delivered. Idempotent: every mutating call checks current state before writing. """ @@ -14,7 +10,7 @@ from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunTaskExecution -from ergon_core.core.runtime.services.task_cleanup_dto import CleanupResult +from ergon_core.core.application.tasks.models import CleanupResult from sqlmodel import Session, select logger = logging.getLogger(__name__) diff --git a/ergon_core/ergon_core/core/application/tasks/errors.py b/ergon_core/ergon_core/core/application/tasks/errors.py new file mode 100644 index 00000000..b89fbb07 --- /dev/null +++ b/ergon_core/ergon_core/core/application/tasks/errors.py @@ -0,0 +1,94 @@ +"""Errors raised by task-domain services.""" + +from uuid import UUID + +from ergon_core.core.application.graph.errors import GraphError + + +class DelegationError(GraphError): + """Base for delegation-specific errors.""" + + pass + + +class TaskRunningError(DelegationError): + """refine_task called on a node that is currently RUNNING. + + The worker is actively consuming the description; editing it mid-flight + would produce inconsistent behaviour. The caller should cancel or wait + for the task to terminate, then refine + restart. + """ + + def __init__(self, node_id: UUID, current_status: str) -> None: + super().__init__( + f"Cannot refine node {node_id}: status is '{current_status}' " + "(refine is blocked while a worker is running)" + ) + self.node_id = node_id + self.current_status = current_status + + +class TaskNotTerminalError(DelegationError): + """restart_task called on a node that is not in a terminal status. + + Only COMPLETED, FAILED, or CANCELLED nodes can be restarted. A PENDING + node hasn't run yet; a RUNNING node is live - the manager should cancel + first if it wants to restart. + """ + + def __init__(self, node_id: UUID, current_status: str) -> None: + super().__init__( + f"Cannot restart node {node_id}: status is '{current_status}', " + "expected one of 'completed', 'failed', 'cancelled'" + ) + self.node_id = node_id + self.current_status = current_status + + +class TaskAlreadyTerminalError(DelegationError): + """cancel_task called on an already-terminal node.""" + + def __init__(self, node_id: UUID, current_status: str) -> None: + super().__init__(f"Cannot cancel node {node_id}: already terminal ('{current_status}')") + self.node_id = node_id + self.current_status = current_status + + +class CycleDetectedError(DelegationError): + """Raised when plan_subtasks dependency graph contains a cycle.""" + + def __init__(self, remaining_slugs: list[str]) -> None: + super().__init__(f"Cycle detected among task_slugs: {remaining_slugs}") + self.remaining_slugs = remaining_slugs + + +class DuplicateTaskSlugError(DelegationError): + """Raised when plan_subtasks has duplicate task_slug values.""" + + def __init__(self, task_slug: str) -> None: + super().__init__(f"Duplicate task_slug: {task_slug!r}") + self.task_slug = task_slug + + +class UnknownTaskSlugError(DelegationError): + """Raised when depends_on references a task_slug not in the plan.""" + + def __init__(self, slugs: list[str]) -> None: + super().__init__(f"Unknown depends_on task_slugs: {slugs}") + self.slugs = slugs + + +class RunRecordMissingError(DelegationError): + """Raised when a service is asked to mutate a run that has no RunRecord. + + Every run must have a RunRecord (with ``experiment_definition_id``) + before any task/graph service is invoked on it. This is enforced as a + hard invariant so missing fixtures in tests surface as a loud failure. + """ + + def __init__(self, run_id: UUID) -> None: + super().__init__( + f"RunRecord missing for run_id={run_id}; seed a RunRecord before " + "invoking TaskManagementService.", + ) + self.run_id = run_id diff --git a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py b/ergon_core/ergon_core/core/application/tasks/execution.py similarity index 90% rename from ergon_core/ergon_core/core/runtime/services/task_execution_service.py rename to ergon_core/ergon_core/core/application/tasks/execution.py index f6c8d653..32c6fdf5 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_execution_service.py +++ b/ergon_core/ergon_core/core/application/tasks/execution.py @@ -3,7 +3,7 @@ import logging from uuid import UUID -from ergon_core.core.dashboard.provider import get_dashboard_emitter +from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.definitions.models import ( ExperimentDefinition, ExperimentDefinitionTask, @@ -15,23 +15,23 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord, RunTaskExecution -from ergon_core.core.runtime.errors.inngest_errors import ConfigurationError -from ergon_core.core.runtime.execution.propagation import ( - mark_task_failed, - mark_task_failed_by_node, - mark_task_running, -) -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_lookup import GraphNodeLookup -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.orchestration_dto import ( +from ergon_core.core.infrastructure.inngest.errors import ConfigurationError +from ergon_core.core.application.graph.models import MutationMeta +from ergon_core.core.application.graph.lookup import GraphNodeLookup +from ergon_core.core.application.graph.repository import WorkflowGraphRepository +from ergon_core.core.application.workflows.orchestration import ( FailTaskExecutionCommand, FinalizeTaskExecutionCommand, PreparedTaskExecution, PrepareTaskExecutionCommand, ) -from ergon_core.core.utils import require_not_none, utcnow -from sqlalchemy import func +from ergon_core.core.application.graph.propagation import ( + mark_task_failed, + mark_task_failed_by_node, + mark_task_running, +) +from ergon_core.core.application.tasks.repository import TaskExecutionRepository +from ergon_core.core.shared.utils import require_not_none, utcnow from sqlmodel import Session, select logger = logging.getLogger(__name__) @@ -66,6 +66,7 @@ async def _emit_task_status( class TaskExecutionService: def __init__(self) -> None: self._graph_repo = WorkflowGraphRepository() + self._task_execution_repo = TaskExecutionRepository() async def prepare(self, command: PrepareTaskExecutionCommand) -> PreparedTaskExecution: if command.node_id is not None: @@ -138,7 +139,9 @@ async def _prepare_graph_native( run_id=command.run_id, node_id=node_id, definition_worker_id=definition_worker_id, - attempt_number=self._next_attempt_number(session, command.run_id, node_id), + attempt_number=self._task_execution_repo.next_attempt_for_node( + session, command.run_id, node_id + ), status=TaskExecutionStatus.RUNNING, started_at=utcnow(), ) @@ -260,7 +263,9 @@ async def _prepare_definition( definition_task_id=task_id, definition_worker_id=definition_worker_id, node_id=resolved_node_id, - attempt_number=self._next_attempt_number_by_task(session, command.run_id, task_id), + attempt_number=self._task_execution_repo.next_attempt_for_definition_task( + session, command.run_id, task_id + ), status=TaskExecutionStatus.RUNNING, started_at=utcnow(), ) @@ -373,22 +378,3 @@ async def finalize_failure(self, command: FailTaskExecutionCommand) -> None: old_status=graph_status.RUNNING, ) - # -- Helpers --- - - def _next_attempt_number(self, session: Session, run_id: UUID, node_id: UUID) -> int: - count = session.exec( - select(func.count(RunTaskExecution.id)).where( - RunTaskExecution.run_id == run_id, - RunTaskExecution.node_id == node_id, - ) - ).one() - return count + 1 - - def _next_attempt_number_by_task(self, session: Session, run_id: UUID, task_id: UUID) -> int: - count = session.exec( - select(func.count(RunTaskExecution.id)).where( - RunTaskExecution.run_id == run_id, - RunTaskExecution.definition_task_id == task_id, - ) - ).one() - return count + 1 diff --git a/ergon_core/ergon_core/core/runtime/services/task_inspection_service.py b/ergon_core/ergon_core/core/application/tasks/inspection.py similarity index 84% rename from ergon_core/ergon_core/core/runtime/services/task_inspection_service.py rename to ergon_core/ergon_core/core/application/tasks/inspection.py index 76f5186c..53ef3eed 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_inspection_service.py +++ b/ergon_core/ergon_core/core/application/tasks/inspection.py @@ -9,8 +9,8 @@ from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode from ergon_core.core.persistence.graph.status_conventions import COMPLETED, FAILED -from ergon_core.core.persistence.telemetry.models import RunTaskExecution -from ergon_core.core.runtime.services.task_inspection_dto import SubtaskInfo +from ergon_core.core.application.tasks.models import SubtaskInfo +from ergon_core.core.application.tasks.repository import TaskExecutionRepository from sqlmodel import Session, select logger = logging.getLogger(__name__) @@ -25,6 +25,9 @@ class TaskInspectionService: to decide which subtasks to cancel, refine, or wait on. """ + def __init__(self) -> None: + self._task_execution_repo = TaskExecutionRepository() + def list_subtasks( self, session: Session, @@ -92,12 +95,7 @@ def _hydrate(self, session: Session, node: RunGraphNode) -> SubtaskInfo: def _latest_output(self, session: Session, node_id: UUID) -> str | None: """Truncated final_assistant_message from the most recent execution.""" - exe = session.exec( - select(RunTaskExecution) - .where(RunTaskExecution.node_id == node_id) - .order_by(RunTaskExecution.started_at.desc()) # type: ignore[union-attr] - .limit(1) - ).first() + exe = self._task_execution_repo.latest_for_node(session, node_id) if exe is None or exe.final_assistant_message is None: return None text = exe.final_assistant_message @@ -105,12 +103,7 @@ def _latest_output(self, session: Session, node_id: UUID) -> str | None: def _latest_error(self, session: Session, node_id: UUID) -> str | None: """Error message from the most recent execution.""" - exe = session.exec( - select(RunTaskExecution) - .where(RunTaskExecution.node_id == node_id) - .order_by(RunTaskExecution.started_at.desc()) # type: ignore[union-attr] - .limit(1) - ).first() + exe = self._task_execution_repo.latest_for_node(session, node_id) if exe is None or exe.error_json is None: return None return str(exe.error_json.get("message", exe.error_json)) diff --git a/ergon_core/ergon_core/core/runtime/services/task_management_service.py b/ergon_core/ergon_core/core/application/tasks/management.py similarity index 86% rename from ergon_core/ergon_core/core/runtime/services/task_management_service.py rename to ergon_core/ergon_core/core/application/tasks/management.py index b98a2c35..71e3503b 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_management_service.py +++ b/ergon_core/ergon_core/core/application/tasks/management.py @@ -10,10 +10,12 @@ from uuid import UUID import inngest -from ergon_core.core.dashboard.emitter import DashboardEmitter -from ergon_core.core.dashboard.provider import get_dashboard_emitter +from ergon_core.api.registry import registry +from ergon_core.core.infrastructure.dashboard.emitter import DashboardEmitter +from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.graph.status_conventions import ( + BLOCKED, CANCELLED, COMPLETED, EDGE_PENDING, @@ -22,8 +24,8 @@ TERMINAL_STATUSES, ) from ergon_core.core.persistence.shared.types import NodeId, TaskSlug -from ergon_core.core.persistence.telemetry.models import RunRecord, RunTaskExecution -from ergon_core.core.runtime.errors.delegation_errors import ( +from ergon_core.core.persistence.telemetry.models import RunRecord +from ergon_core.core.application.tasks.errors import ( CycleDetectedError, DuplicateTaskSlugError, RunRecordMissingError, @@ -32,17 +34,20 @@ TaskRunningError, UnknownTaskSlugError, ) -from ergon_core.core.runtime.events.task_events import ( +from ergon_core.core.application.events.task_events import ( + PropagationCancelCause, TaskCancelledEvent, TaskReadyEvent, ) -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.task_management_dto import ( +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.application.graph.traversal import descendants +from ergon_core.core.application.graph.models import MutationMeta +from ergon_core.core.application.graph.repository import WorkflowGraphRepository +from ergon_core.core.application.tasks.models import ( AddSubtaskCommand, AddSubtaskResult, CancelTaskCommand, + CancelOrphansResult, CancelTaskResult, PlanSubtasksCommand, PlanSubtasksResult, @@ -52,6 +57,7 @@ RestartTaskResult, SubtaskSpec, ) +from ergon_core.core.application.tasks.repository import TaskExecutionRepository from sqlmodel import Session, select logger = logging.getLogger(__name__) @@ -65,46 +71,15 @@ def _count_non_terminal_descendants(session: Session, run_id: UUID, node_id: UUI Uses Python-level BFS rather than a recursive CTE so the logic is portable across SQLite (tests) and Postgres (production). """ - count = 0 - queue: deque[UUID] = deque([node_id]) - while queue: - parent = queue.popleft() - children = session.exec( - select(RunGraphNode.id, RunGraphNode.status).where( - RunGraphNode.run_id == run_id, - RunGraphNode.parent_node_id == parent, - ) - ).all() - for child_id, child_status in children: - if child_status not in TERMINAL_STATUSES: - count += 1 - queue.append(child_id) - return count - - -def _latest_execution_id(session: Session, node_id: UUID) -> UUID | None: - """Most recent execution for a node, or None. - - Used to attach execution_id to TaskCancelledEvent so the cleanup - function can release the correct sandbox. - """ - exe = session.exec( - select(RunTaskExecution.id) - .where(RunTaskExecution.node_id == node_id) - .order_by(RunTaskExecution.started_at.desc()) # type: ignore[union-attr] - .limit(1) - ).first() - return exe + return sum( + 1 + for descendant in descendants(session, run_id=run_id, root_node_id=node_id) + if descendant.status not in TERMINAL_STATUSES + ) class TaskManagementService: - """Agent-initiated subtask lifecycle operations. - - Separated from TaskInspectionService (read-only) and - SubtaskCancellationService (engine-driven cascade) because this - service is the only one called from agent tool closures during - the manager's ReAct loop. - """ + """Task lifecycle mutations for manager actions and engine cascades.""" def __init__( self, @@ -112,6 +87,7 @@ def __init__( dashboard_emitter: DashboardEmitter | None = None, ) -> None: self._graph_repo = graph_repo or WorkflowGraphRepository() + self._task_execution_repo = TaskExecutionRepository() self._dashboard_emitter = dashboard_emitter or get_dashboard_emitter() self._graph_repo.add_mutation_listener(self._dashboard_emitter.graph_mutation) @@ -129,11 +105,8 @@ async def add_subtask( dependency edges (source=dep, target=new_node). """ task_slug = command.task_slug - from ergon_builtins.registry import ( # slopcop: ignore[guarded-function-import] -- reason: dynamic task creation validates plugin worker slugs only when manager tools run - WORKERS, - ) - if command.assigned_worker_slug not in WORKERS: + if command.assigned_worker_slug not in registry.workers: raise ValueError(f"Unknown worker slug: {command.assigned_worker_slug!r}") parent = self._graph_repo.get_node( @@ -226,7 +199,9 @@ async def cancel_task( if applied: definition_id = self._resolve_definition_id(session, command.run_id) - execution_id = _latest_execution_id(session, command.node_id) + execution_id = self._task_execution_repo.latest_execution_id_for_node( + session, command.node_id + ) event = TaskCancelledEvent( run_id=command.run_id, definition_id=definition_id, @@ -254,6 +229,77 @@ async def cancel_task( cascaded_count=cascaded, ) + async def cancel_orphans( + self, + session: Session, + *, + run_id: UUID, + definition_id: UUID, + parent_node_id: UUID, + cause: PropagationCancelCause, + ) -> CancelOrphansResult: + """Cancel every non-terminal containment descendant of parent_node_id.""" + meta = MutationMeta(actor="system:cascade", reason=cause) + transitioned: list[UUID] = [] + + for child in descendants(session, run_id=run_id, root_node_id=parent_node_id): + if child.status in TERMINAL_STATUSES: + continue + applied = await self._graph_repo.update_node_status( + session, + run_id=run_id, + node_id=child.id, + new_status=CANCELLED, + meta=meta, + only_if_not_terminal=True, + ) + if applied: + transitioned.append(child.id) + + events = [ + TaskCancelledEvent( + run_id=run_id, + definition_id=definition_id, + node_id=nid, + execution_id=self._task_execution_repo.latest_execution_id_for_node(session, nid), + cause=cause, + ) + for nid in transitioned + ] + return CancelOrphansResult( + parent_node_id=parent_node_id, + cancelled_node_ids=transitioned, + events_to_emit=events, + ) + + async def block_pending_descendants( + self, + session: Session, + *, + run_id: UUID, + parent_node_id: UUID, + cause: str, + ) -> list[UUID]: + """Block non-terminal, non-running containment descendants.""" + meta = MutationMeta(actor="system:cascade", reason=cause) + blocked: list[UUID] = [] + + for child in descendants(session, run_id=run_id, root_node_id=parent_node_id): + if child.status == RUNNING or child.status in TERMINAL_STATUSES: + continue + applied = await self._graph_repo.update_node_status( + session, + run_id=run_id, + node_id=child.id, + new_status=BLOCKED, + meta=meta, + only_if_not_terminal=True, + ) + if applied: + blocked.append(child.id) + + return blocked + # ── plan_subtasks ──────────────────────────────────────── async def plan_subtasks( @@ -268,12 +314,9 @@ async def plan_subtasks( root tasks (those with no depends_on). """ self._validate_plan(command.subtasks) - from ergon_builtins.registry import ( # slopcop: ignore[guarded-function-import] -- reason: dynamic task creation validates plugin worker slugs only when manager tools run - WORKERS, - ) for spec in command.subtasks: - if spec.assigned_worker_slug not in WORKERS: + if spec.assigned_worker_slug not in registry.workers: raise ValueError(f"Unknown worker slug: {spec.assigned_worker_slug!r}") parent = self._graph_repo.get_node( @@ -569,7 +612,7 @@ async def _cancel_for_invalidation( ) definition_id = self._resolve_definition_id(session, run_id) - execution_id = _latest_execution_id(session, node_id) + execution_id = self._task_execution_repo.latest_execution_id_for_node(session, node_id) event = TaskCancelledEvent( run_id=run_id, definition_id=definition_id, diff --git a/ergon_core/ergon_core/core/runtime/services/task_management_dto.py b/ergon_core/ergon_core/core/application/tasks/models.py similarity index 79% rename from ergon_core/ergon_core/core/runtime/services/task_management_dto.py rename to ergon_core/ergon_core/core/application/tasks/models.py index 8a30d9aa..6016c36f 100644 --- a/ergon_core/ergon_core/core/runtime/services/task_management_dto.py +++ b/ergon_core/ergon_core/core/application/tasks/models.py @@ -1,18 +1,17 @@ -"""DTOs for TaskManagementService — subtask lifecycle commands and results. +"""Task-domain request and response models.""" -UUID fields use NewType aliases so type checkers catch cross-field -swaps at the call boundary. -""" +from uuid import UUID +from ergon_core.core.persistence.graph.status_conventions import NodeStatus from ergon_core.core.persistence.shared.types import ( AssignedWorkerSlug, NodeId, RunId, TaskSlug, ) +from ergon_core.core.application.events.task_events import TaskCancelledEvent from pydantic import BaseModel, Field -# ── add_subtask ──────────────────────────────────────────────────────────── class AddSubtaskCommand(BaseModel): @@ -150,3 +149,38 @@ class RestartTaskResult(BaseModel): invalidated_node_ids: list[NodeId] = Field(default_factory=list) model_config = {"frozen": True} + +class CancelOrphansResult(BaseModel): + """Result of cascade-cancelling non-terminal children of a parent node.""" + + parent_node_id: NodeId + cancelled_node_ids: list[NodeId] + events_to_emit: list[TaskCancelledEvent] + + model_config = {"frozen": True} + + +class SubtaskInfo(BaseModel): + """A snapshot of one subtask suitable for the manager to reason over.""" + + node_id: NodeId + task_slug: str + description: str + status: NodeStatus + depends_on: list[NodeId] + output: str | None + error: str | None + + model_config = {"frozen": True} + + +class CleanupResult(BaseModel): + """Result of cleaning up a cancelled task execution.""" + + run_id: RunId + node_id: NodeId + execution_id: UUID | None + sandbox_released: bool + execution_row_updated: bool + + model_config = {"frozen": True} diff --git a/ergon_core/ergon_core/core/application/tasks/repository.py b/ergon_core/ergon_core/core/application/tasks/repository.py new file mode 100644 index 00000000..730ab810 --- /dev/null +++ b/ergon_core/ergon_core/core/application/tasks/repository.py @@ -0,0 +1,111 @@ +"""Task execution domain repository.""" + +from uuid import UUID +from typing import TypeVar + +from ergon_core.core.persistence.definitions.models import ExperimentDefinitionTask +from ergon_core.core.persistence.graph.models import RunGraphNode +from ergon_core.core.persistence.telemetry.models import RunTaskExecution +from pydantic import BaseModel +from sqlalchemy import func +from sqlmodel import Session, col, select + +PayloadModelT = TypeVar("PayloadModelT", bound=BaseModel) + + +class TaskExecutionRepository: + """Domain queries over task execution rows.""" + + def latest_for_node(self, session: Session, node_id: UUID) -> RunTaskExecution | None: + stmt = ( + select(RunTaskExecution) + .where(RunTaskExecution.node_id == node_id) + .order_by( + col(RunTaskExecution.attempt_number).desc(), + col(RunTaskExecution.started_at).desc(), + ) + .limit(1) + ) + return session.exec(stmt).first() + + def latest_execution_id_for_node(self, session: Session, node_id: UUID) -> UUID | None: + execution = self.latest_for_node(session, node_id) + return None if execution is None else execution.id + + def latest_for_definition_task( + self, + session: Session, + run_id: UUID, + definition_task_id: UUID, + ) -> RunTaskExecution | None: + stmt = ( + select(RunTaskExecution) + .where( + RunTaskExecution.run_id == run_id, + RunTaskExecution.definition_task_id == definition_task_id, + ) + .order_by( + col(RunTaskExecution.attempt_number).desc(), + col(RunTaskExecution.started_at).desc(), + ) + .limit(1) + ) + return session.exec(stmt).first() + + def list_children_of_execution( + self, + session: Session, + parent_execution_id: UUID, + ) -> list[RunTaskExecution]: + parent = session.get(RunTaskExecution, parent_execution_id) + if parent is None or parent.node_id is None: + return [] + child_node_ids_stmt = select(RunGraphNode.id).where( + RunGraphNode.parent_node_id == parent.node_id + ) + stmt = select(RunTaskExecution).where( + col(RunTaskExecution.node_id).in_(child_node_ids_stmt) + ) + return list(session.exec(stmt).all()) + + def task_payload_for_execution( + self, + session: Session, + task_execution_id: UUID, + payload_model: type[PayloadModelT], + ) -> PayloadModelT | None: + stmt = ( + select(ExperimentDefinitionTask) + .join( + RunTaskExecution, + RunTaskExecution.definition_task_id == ExperimentDefinitionTask.id, + ) + .where(RunTaskExecution.id == task_execution_id) + ) + result = session.exec(stmt).first() + if result is None: + return None + return result.task_payload_as(payload_model) + + def next_attempt_for_node(self, session: Session, run_id: UUID, node_id: UUID) -> int: + count = session.exec( + select(func.count(RunTaskExecution.id)).where( + RunTaskExecution.run_id == run_id, + RunTaskExecution.node_id == node_id, + ) + ).one() + return count + 1 + + def next_attempt_for_definition_task( + self, + session: Session, + run_id: UUID, + definition_task_id: UUID, + ) -> int: + count = session.exec( + select(func.count(RunTaskExecution.id)).where( + RunTaskExecution.run_id == run_id, + RunTaskExecution.definition_task_id == definition_task_id, + ) + ).one() + return count + 1 diff --git a/ergon_core/ergon_core/core/application/tasks/service.py b/ergon_core/ergon_core/core/application/tasks/service.py new file mode 100644 index 00000000..1d6e3470 --- /dev/null +++ b/ergon_core/ergon_core/core/application/tasks/service.py @@ -0,0 +1,10 @@ +"""Task application package front door. + +Task lifecycle behavior currently lives in focused modules: +`execution`, `management`, `inspection`, and `cleanup`. +""" + +from ergon_core.core.application.tasks.execution import TaskExecutionService +from ergon_core.core.application.tasks.management import TaskManagementService + +__all__ = ["TaskExecutionService", "TaskManagementService"] diff --git a/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py b/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py deleted file mode 100644 index 400d04f2..00000000 --- a/ergon_core/ergon_core/core/runtime/services/orchestration_dto.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Typed command/result DTOs for orchestration services. - -These are the contracts between Inngest functions and services. -Adapted from ref: definition_id replaces experiment_id. -""" - -import sys -from datetime import datetime - -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from enum import Enum - - class StrEnum(str, Enum): - pass - - -from uuid import UUID - -from ergon_core.core.json_types import JsonObject -from pydantic import BaseModel, Field - - -class TaskDescriptor(BaseModel): - """Lightweight task reference for orchestration steps.""" - - model_config = {"frozen": True} - - task_id: UUID | None = None - task_slug: str - parent_task_id: UUID | None = None - node_id: UUID | None = None - - -class InitializeWorkflowCommand(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - definition_id: UUID - - -class InitializedWorkflow(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - definition_id: UUID - benchmark_type: str - total_tasks: int - total_root_tasks: int - pending_tasks: list[TaskDescriptor] = Field(default_factory=list) - initial_ready_tasks: list[TaskDescriptor] = Field(default_factory=list) - - -class PrepareTaskExecutionCommand(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - definition_id: UUID - task_id: UUID | None - node_id: UUID | None = None - - -class PreparedTaskExecution(BaseModel): - """Output of ``TaskExecutionService.prepare``. - - ``node_id`` is the runtime task identity (= ``RunGraphNode.id``); - always non-null because every task execution is attached to a - graph node. ``definition_task_id`` is the optional FK to the - static ``ExperimentDefinitionTask`` row — null for dynamically - spawned subtasks which have no compile-time declaration. This - split replaces the earlier ``task_id: UUID`` field, which was dead - downstream (never read) but required a non-null value that - dynamic-subtask preparation could not provide, crashing on the - Pydantic boundary. See docs/bugs/open/2026-04-23-inngest-function-failures.md § A. - """ - - model_config = {"frozen": True} - - run_id: UUID - definition_id: UUID - node_id: UUID - definition_task_id: UUID | None = None - task_slug: str - task_description: str - benchmark_type: str - assigned_worker_slug: str - worker_type: str - model_target: str - execution_id: UUID - skipped: bool = False - skip_reason: str | None = None - - -class FinalizeTaskExecutionCommand(BaseModel): - model_config = {"frozen": True} - - execution_id: UUID - final_assistant_message: str | None = None - output_resource_ids: list[UUID] = Field(default_factory=list) - - -class FailTaskExecutionCommand(BaseModel): - model_config = {"frozen": True} - - execution_id: UUID - run_id: UUID - task_id: UUID | None - error_message: str - error_json: JsonObject | None = None - - -class WorkflowTerminalState(StrEnum): - NONE = "none" - COMPLETED = "completed" - FAILED = "failed" - - -class PropagateTaskCompletionCommand(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - definition_id: UUID - task_id: UUID | None - execution_id: UUID - node_id: UUID | None = None - - -class PropagationResult(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - definition_id: UUID - completed_task_id: UUID | None - ready_tasks: list[TaskDescriptor] = Field(default_factory=list) - workflow_terminal_state: WorkflowTerminalState = WorkflowTerminalState.NONE - - -class FinalizeWorkflowCommand(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - definition_id: UUID - - -class FinalizedWorkflowResult(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - final_score: float | None = None - normalized_score: float | None = None - evaluators_count: int = 0 - - -class RunCompletionData(BaseModel): - """Atomic bundle passed into run completion persistence.""" - - model_config = {"frozen": True} - - completed_at: datetime - final_score: float | None = None - normalized_score: float | None = None - total_cost_usd: float = 0.0 - execution_result: JsonObject = Field(default_factory=dict) diff --git a/ergon_core/ergon_core/core/runtime/services/subtask_blocking_service.py b/ergon_core/ergon_core/core/runtime/services/subtask_blocking_service.py deleted file mode 100644 index 1fd00eef..00000000 --- a/ergon_core/ergon_core/core/runtime/services/subtask_blocking_service.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Block PENDING/READY containment descendants when a parent fails. - -Walks the containment axis (parent_node_id) via BFS and marks every -non-terminal, non-running descendant as BLOCKED in a single transaction. -BLOCKED means "predecessor failed; operator action required." - -Distinct from SubtaskCancellationService which writes CANCELLED (intentional -stop). BLOCKED is never written by operator actions — only by propagation. -""" - -from collections import deque -from uuid import UUID - -from ergon_core.core.persistence.graph.models import RunGraphNode -from ergon_core.core.persistence.graph.status_conventions import BLOCKED, RUNNING, TERMINAL_STATUSES -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from sqlmodel import Session, select - - -class SubtaskBlockingService: - """Recursively blocks non-terminal, non-running containment descendants.""" - - def __init__(self, graph_repo: WorkflowGraphRepository | None = None) -> None: - self._graph_repo = graph_repo or WorkflowGraphRepository() - - async def block_pending_descendants( - self, - session: Session, - *, - run_id: UUID, - parent_node_id: UUID, - cause: str, - ) -> list[UUID]: - """Recursively BLOCK all PENDING/READY descendants of parent_node_id. - - RUNNING descendants are skipped — live executions continue to their - own terminal. Terminal descendants are skipped via only_if_not_terminal. - - Returns IDs of nodes that were transitioned to BLOCKED. - """ - meta = MutationMeta(actor="system:cascade", reason=cause) - blocked: list[UUID] = [] - - queue: deque[UUID] = deque([parent_node_id]) - while queue: - current_parent = queue.popleft() - children = session.exec( - select(RunGraphNode.id, RunGraphNode.status).where( - RunGraphNode.run_id == run_id, - RunGraphNode.parent_node_id == current_parent, - ) - ).all() - - for child_id, child_status in children: - queue.append(child_id) # always recurse into grandchildren - - if child_status == RUNNING or child_status in TERMINAL_STATUSES: - continue - - applied = await self._graph_repo.update_node_status( - session, - run_id=run_id, - node_id=child_id, - new_status=BLOCKED, - meta=meta, - only_if_not_terminal=True, - ) - if applied: - blocked.append(child_id) - - return blocked diff --git a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_dto.py b/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_dto.py deleted file mode 100644 index 7a59c1bc..00000000 --- a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_dto.py +++ /dev/null @@ -1,15 +0,0 @@ -"""DTOs for SubtaskCancellationService.""" - -from ergon_core.core.persistence.shared.types import NodeId -from ergon_core.core.runtime.events.task_events import TaskCancelledEvent -from pydantic import BaseModel - - -class CancelOrphansResult(BaseModel): - """Result of cascade-cancelling non-terminal children of a parent node.""" - - parent_node_id: NodeId - cancelled_node_ids: list[NodeId] - events_to_emit: list[TaskCancelledEvent] - - model_config = {"frozen": True} diff --git a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py b/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py deleted file mode 100644 index e7e130b3..00000000 --- a/ergon_core/ergon_core/core/runtime/services/subtask_cancellation_service.py +++ /dev/null @@ -1,125 +0,0 @@ -"""SubtaskCancellationService — recursive cascade cancel. - -Walks the entire descendant subtree of a parent node via BFS and -marks every non-terminal node as CANCELLED in a single transaction. -Returns task/cancelled events for each transitioned node so the -caller can trigger per-node cleanup (sandbox teardown, execution -row update) via Inngest. -""" - -import logging -from collections import deque -from uuid import UUID - -from ergon_core.core.persistence.graph.models import RunGraphNode -from ergon_core.core.persistence.graph.status_conventions import ( - CANCELLED, - TERMINAL_STATUSES, -) -from ergon_core.core.persistence.telemetry.models import RunTaskExecution -from ergon_core.core.runtime.events.task_events import PropagationCancelCause, TaskCancelledEvent -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.subtask_cancellation_dto import CancelOrphansResult -from sqlmodel import Session, select - -logger = logging.getLogger(__name__) - - -class SubtaskCancellationService: - """Recursively cancels all non-terminal descendants of a parent node. - - Uses BFS on parent_node_id to walk the full subtree in one DB - transaction. This avoids relying on Inngest event chains for - recursion — a dropped or delayed event can't leave grandchildren - running under a cancelled parent. - - Separated from TaskCleanupService because cancellation fans out - (one parent -> N descendants in a single DB transaction) while - cleanup runs per-node (sandbox teardown, execution row update). - - Separated from TaskManagementService because that service handles - agent-initiated commands while this service is called exclusively - by the engine (Inngest cascade function). - """ - - def __init__(self, graph_repo: WorkflowGraphRepository | None = None) -> None: - self._graph_repo = graph_repo or WorkflowGraphRepository() - - async def cancel_orphans( - self, - session: Session, - *, - run_id: UUID, - definition_id: UUID, - parent_node_id: UUID, - cause: PropagationCancelCause, - ) -> CancelOrphansResult: - """Recursively cancel every non-terminal descendant of parent_node_id. - - Walks the subtree via BFS on parent_node_id. Each non-terminal - node is marked CANCELLED with the first-writer-wins guard. - Returns events for caller to emit after DB commit succeeds — - each event triggers per-node cleanup (sandbox release, etc). - """ - meta = MutationMeta(actor="system:cascade", reason=cause) - transitioned: list[UUID] = [] - - queue: deque[UUID] = deque([parent_node_id]) - while queue: - current_parent = queue.popleft() - children = session.exec( - select(RunGraphNode.id, RunGraphNode.status).where( - RunGraphNode.run_id == run_id, - RunGraphNode.parent_node_id == current_parent, - ) - ).all() - - for child_id, child_status in children: - # Always enqueue so we walk the full tree, even past - # already-terminal nodes (their children might not be). - queue.append(child_id) - - if child_status in TERMINAL_STATUSES: - continue - applied = await self._graph_repo.update_node_status( - session, - run_id=run_id, - node_id=child_id, - new_status=CANCELLED, - meta=meta, - only_if_not_terminal=True, - ) - if applied: - transitioned.append(child_id) - - events = [ - TaskCancelledEvent( - run_id=run_id, - definition_id=definition_id, - node_id=nid, - execution_id=_latest_execution_id(session, nid), - cause=cause, - ) - for nid in transitioned - ] - return CancelOrphansResult( - parent_node_id=parent_node_id, - cancelled_node_ids=transitioned, - events_to_emit=events, - ) - - -def _latest_execution_id(session: Session, node_id: UUID) -> UUID | None: - """Most recent execution for a node, or None. - - Duplicated from task_management_service — both services need it - independently to populate TaskCancelledEvent.execution_id. - """ - exe = session.exec( - select(RunTaskExecution.id) - .where(RunTaskExecution.node_id == node_id) - .order_by(RunTaskExecution.started_at.desc()) # type: ignore[union-attr] - .limit(1) - ).first() - return exe diff --git a/ergon_core/ergon_core/core/runtime/services/task_cleanup_dto.py b/ergon_core/ergon_core/core/runtime/services/task_cleanup_dto.py deleted file mode 100644 index 70285f81..00000000 --- a/ergon_core/ergon_core/core/runtime/services/task_cleanup_dto.py +++ /dev/null @@ -1,18 +0,0 @@ -"""DTOs for TaskCleanupService.""" - -from uuid import UUID - -from ergon_core.core.persistence.shared.types import NodeId, RunId -from pydantic import BaseModel - - -class CleanupResult(BaseModel): - """Result of cleaning up a cancelled task execution.""" - - run_id: RunId - node_id: NodeId - execution_id: UUID | None - sandbox_released: bool - execution_row_updated: bool - - model_config = {"frozen": True} diff --git a/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py b/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py deleted file mode 100644 index 72cdac77..00000000 --- a/ergon_core/ergon_core/core/runtime/services/task_inspection_dto.py +++ /dev/null @@ -1,19 +0,0 @@ -"""DTOs for TaskInspectionService — read-only subtask queries.""" - -from ergon_core.core.persistence.graph.status_conventions import NodeStatus -from ergon_core.core.persistence.shared.types import NodeId -from pydantic import BaseModel - - -class SubtaskInfo(BaseModel): - """A snapshot of one subtask suitable for the manager to reason over.""" - - node_id: NodeId - task_slug: str - description: str - status: NodeStatus - depends_on: list[NodeId] - output: str | None - error: str | None - - model_config = {"frozen": True} diff --git a/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py b/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py deleted file mode 100644 index 653fb57c..00000000 --- a/ergon_core/ergon_core/core/runtime/services/task_propagation_service.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Task propagation: resolve DAG dependencies and detect terminal states.""" - -from uuid import UUID - -from ergon_core.core.persistence.graph import status_conventions as graph_status -from ergon_core.core.persistence.graph.models import RunGraphNode -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.runtime.execution.propagation import ( - is_workflow_complete_v2, - is_workflow_failed_v2, - on_task_completed_or_failed, -) -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_lookup import GraphNodeLookup -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.orchestration_dto import ( - PropagateTaskCompletionCommand, - PropagationResult, - TaskDescriptor, - WorkflowTerminalState, -) - - -class TaskPropagationService: - """Resolve DAG dependencies after a task reaches a terminal state. - - Separated from the Inngest wrappers so the dependency resolution logic - is testable without an event loop. Each method opens its own session - because the caller (an Inngest step function) may retry independently. - """ - - async def propagate(self, command: PropagateTaskCompletionCommand) -> PropagationResult: - """Handle successful task completion: satisfy deps and schedule ready tasks. - - Returns newly-ready tasks for scheduling. Failure propagation blocks - downstream graph nodes in the database and does not emit cancellation - events from this contract. - """ - with get_session() as session: - graph_repo = WorkflowGraphRepository() - - node_id = command.node_id - if node_id is None: - graph_lookup = GraphNodeLookup(session, command.run_id) - node_id = graph_lookup.node_id(command.task_id) - if node_id is None: - return PropagationResult( - run_id=command.run_id, - definition_id=command.definition_id, - completed_task_id=command.task_id, - workflow_terminal_state=WorkflowTerminalState.NONE, - ) - - # Mark the triggering node as COMPLETED before propagating edges. - # on_task_completed_or_failed only updates edges and downstream - # candidates — the node's own status must be set by the caller. - await graph_repo.update_node_status( - session, - run_id=command.run_id, - node_id=node_id, - new_status=graph_status.COMPLETED, - meta=MutationMeta( - actor="system:propagation", - reason=f"task {command.task_id} completed", - ), - only_if_not_terminal=True, - ) - - newly_ready_node_ids = await on_task_completed_or_failed( - session, - command.run_id, - node_id, - graph_status.COMPLETED, - graph_repo=graph_repo, - ) - - ready_descriptors: list[TaskDescriptor] = [] - for ready_node_id in newly_ready_node_ids: - rn = session.get(RunGraphNode, ready_node_id) - if rn is not None: - ready_descriptors.append( - TaskDescriptor( - task_id=rn.definition_task_id, - task_slug=rn.task_slug, - node_id=ready_node_id, - ) - ) - - terminal = WorkflowTerminalState.NONE - if is_workflow_complete_v2(session, command.run_id): - terminal = WorkflowTerminalState.COMPLETED - elif is_workflow_failed_v2(session, command.run_id): - terminal = WorkflowTerminalState.FAILED - - return PropagationResult( - run_id=command.run_id, - definition_id=command.definition_id, - completed_task_id=command.task_id, - ready_tasks=ready_descriptors, - workflow_terminal_state=terminal, - ) - - async def operator_unblock(self, *, run_id: UUID, node_id: UUID, reason: str) -> None: - """Operator action: transition a BLOCKED node back to PENDING. - - BLOCKED is non-terminal so the default only_if_not_terminal guard is - not needed here, but we write unconditionally so it also works if the - node was somehow left in another non-terminal state. - """ - with get_session() as session: - graph_repo = WorkflowGraphRepository() - await graph_repo.update_node_status( - session, - run_id=run_id, - node_id=node_id, - new_status=graph_status.PENDING, - meta=MutationMeta(actor="operator:unblock", reason=reason), - ) - session.commit() - - async def restart_node(self, *, run_id: UUID, node_id: UUID, reason: str) -> None: - """Operator action: restart a FAILED node by transitioning it back to PENDING. - - FAILED is terminal, so only_if_not_terminal must NOT be used here — - this is an explicit operator override that reverses a terminal status. - """ - with get_session() as session: - graph_repo = WorkflowGraphRepository() - await graph_repo.update_node_status( - session, - run_id=run_id, - node_id=node_id, - new_status=graph_status.PENDING, - meta=MutationMeta(actor="operator:restart", reason=reason), - ) - session.commit() - - async def propagate_failure(self, command: PropagateTaskCompletionCommand) -> PropagationResult: - """Handle task failure: block downstream graph nodes, detect workflow terminal. - - Unlike propagate(), never produces newly-ready tasks. A failed source - invalidates outgoing edges and transitions reachable successors to - BLOCKED unless they are RUNNING or terminal. - """ - with get_session() as session: - graph_repo = WorkflowGraphRepository() - - node_id = command.node_id - if node_id is None: - graph_lookup = GraphNodeLookup(session, command.run_id) - node_id = graph_lookup.node_id(command.task_id) - - if node_id is not None: - # Mark the triggering node as FAILED before propagating edges. - await graph_repo.update_node_status( - session, - run_id=command.run_id, - node_id=node_id, - new_status=graph_status.FAILED, - meta=MutationMeta( - actor="system:propagation", - reason=f"task {command.task_id} failed", - ), - only_if_not_terminal=True, - ) - - await on_task_completed_or_failed( - session, - command.run_id, - node_id, - graph_status.FAILED, - graph_repo=graph_repo, - ) - - terminal = WorkflowTerminalState.NONE - if is_workflow_failed_v2(session, command.run_id): - terminal = WorkflowTerminalState.FAILED - - return PropagationResult( - run_id=command.run_id, - definition_id=command.definition_id, - completed_task_id=command.task_id, - workflow_terminal_state=terminal, - ) From 1cba9092dc45013b2d8d6135ce0479a020204f02 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:45 +0100 Subject: [PATCH 52/66] refactor: move evaluation application services Made-with: Cursor --- .../core/application/evaluation/__init__.py | 0 .../evaluation/criterion_runtime.py | 21 +-- .../core/application/evaluation/errors.py | 6 + .../evaluation/executors.py | 10 +- .../evaluation/inngest_executor.py | 33 ++-- .../evaluation/models.py} | 38 +++- .../evaluation/protocols.py | 6 +- .../core/application/evaluation/scoring.py | 32 ++++ .../evaluation/service.py} | 178 +++++++++++++++--- .../core/runtime/services/evaluation_dto.py | 37 ---- .../services/evaluator_dispatch_service.py | 93 --------- .../services/rubric_evaluation_service.py | 63 ------- 12 files changed, 256 insertions(+), 261 deletions(-) create mode 100644 ergon_core/ergon_core/core/application/evaluation/__init__.py rename ergon_core/ergon_core/core/{runtime => application}/evaluation/criterion_runtime.py (95%) create mode 100644 ergon_core/ergon_core/core/application/evaluation/errors.py rename ergon_core/ergon_core/core/{runtime => application}/evaluation/executors.py (63%) rename ergon_core/ergon_core/core/{runtime => application}/evaluation/inngest_executor.py (85%) rename ergon_core/ergon_core/core/{runtime/evaluation/evaluation_schemas.py => application/evaluation/models.py} (57%) rename ergon_core/ergon_core/core/{runtime => application}/evaluation/protocols.py (91%) create mode 100644 ergon_core/ergon_core/core/application/evaluation/scoring.py rename ergon_core/ergon_core/core/{runtime/services/evaluation_persistence_service.py => application/evaluation/service.py} (54%) delete mode 100644 ergon_core/ergon_core/core/runtime/services/evaluation_dto.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/evaluator_dispatch_service.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/rubric_evaluation_service.py diff --git a/ergon_core/ergon_core/core/application/evaluation/__init__.py b/ergon_core/ergon_core/core/application/evaluation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py b/ergon_core/ergon_core/core/application/evaluation/criterion_runtime.py similarity index 95% rename from ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py rename to ergon_core/ergon_core/core/application/evaluation/criterion_runtime.py index 58716002..37c9a5f9 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/criterion_runtime.py +++ b/ergon_core/ergon_core/core/application/evaluation/criterion_runtime.py @@ -6,36 +6,27 @@ from uuid import UUID from e2b import SandboxNotFoundException, TimeoutException -from ergon_core.core.runtime.evaluation.protocols import ( +from ergon_core.core.application.evaluation.protocols import ( CommandResult, - CriterionRuntime, SandboxResult, ) from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource -from ergon_core.core.sandbox.errors import SandboxExpiredError -from ergon_core.core.sandbox.event_sink import ( +from ergon_core.core.infrastructure.sandbox.errors import SandboxExpiredError +from ergon_core.core.infrastructure.sandbox.event_sink import ( NoopSandboxEventSink, SandboxEventSink, ) -from ergon_core.core.runtime.evaluation.evaluation_schemas import CriterionContext -from ergon_core.core.runtime.resources import RunResourceView +from ergon_core.core.application.evaluation.models import CriterionContext +from ergon_core.core.application.resources import RunResourceView from pydantic import BaseModel, ConfigDict from sqlmodel import Session, desc, select if TYPE_CHECKING: - from ergon_core.core.sandbox.manager import AsyncSandbox, BaseSandboxManager + from ergon_core.core.infrastructure.sandbox.manager import AsyncSandbox, BaseSandboxManager logger = logging.getLogger(__name__) -# Re-export the Protocol so existing imports from this module keep working. -__all__ = [ - "CriterionRuntime", - "CriterionRuntimeOptions", - "DefaultCriterionRuntime", - "ResourceNotFoundError", -] - class ResourceNotFoundError(LookupError): """Raised by ``read_resource`` when no ``RunResource`` row matches the name.""" diff --git a/ergon_core/ergon_core/core/application/evaluation/errors.py b/ergon_core/ergon_core/core/application/evaluation/errors.py new file mode 100644 index 00000000..adb6142d --- /dev/null +++ b/ergon_core/ergon_core/core/application/evaluation/errors.py @@ -0,0 +1,6 @@ +"""Evaluation-domain errors.""" + + +class EvaluationError(Exception): + """Base for evaluation-domain failures.""" + diff --git a/ergon_core/ergon_core/core/runtime/evaluation/executors.py b/ergon_core/ergon_core/core/application/evaluation/executors.py similarity index 63% rename from ergon_core/ergon_core/core/runtime/evaluation/executors.py rename to ergon_core/ergon_core/core/application/evaluation/executors.py index 7fcad30a..4a0687e5 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/executors.py +++ b/ergon_core/ergon_core/core/application/evaluation/executors.py @@ -2,9 +2,9 @@ from typing import Protocol -from ergon_core.api.results import CriterionResult -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.evaluation.evaluation_schemas import ( +from ergon_core.api.criterion import CriterionOutcome +from ergon_core.api.benchmark import Task +from ergon_core.core.application.evaluation.models import ( CriterionSpec, TaskEvaluationContext, ) @@ -16,7 +16,7 @@ class CriterionExecutor(Protocol): async def execute_all( self, task_context: TaskEvaluationContext, - task: BenchmarkTask, + task: Task, benchmark_name: str, criteria: list[CriterionSpec], - ) -> list[CriterionResult]: ... + ) -> list[CriterionOutcome]: ... diff --git a/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py b/ergon_core/ergon_core/core/application/evaluation/inngest_executor.py similarity index 85% rename from ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py rename to ergon_core/ergon_core/core/application/evaluation/inngest_executor.py index 3810fdd4..8f79f9d3 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/inngest_executor.py +++ b/ergon_core/ergon_core/core/application/evaluation/inngest_executor.py @@ -7,19 +7,20 @@ import inngest from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult, WorkerOutput -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.evaluation.criterion_runtime import ( +from ergon_core.api.criterion import CriterionContext as PublicCriterionContext +from ergon_core.api.criterion import CriterionOutcome +from ergon_core.api.benchmark import Task +from ergon_core.api.worker import WorkerOutput +from ergon_core.core.application.evaluation.criterion_runtime import ( CriterionRuntimeOptions, DefaultCriterionRuntime, ) -from ergon_core.core.runtime.evaluation.evaluation_schemas import ( - CriterionContext, +from ergon_core.core.application.evaluation.models import ( + CriterionContext as EngineCriterionContext, CriterionSpec, TaskEvaluationContext, ) -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, TraceSink, evaluation_criterion_context, @@ -27,7 +28,7 @@ ) if TYPE_CHECKING: - from ergon_core.core.sandbox.manager import BaseSandboxManager + from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager class InngestCriterionExecutor: @@ -53,14 +54,14 @@ def __init__( async def execute_all( self, task_context: TaskEvaluationContext, - task: BenchmarkTask, + task: Task, benchmark_name: str, criteria: list[CriterionSpec], - ) -> list[CriterionResult]: + ) -> list[CriterionOutcome]: def make_step(spec: CriterionSpec): - async def run_criterion() -> CriterionResult: + async def run_criterion() -> CriterionOutcome: span_start = datetime.now(UTC) - criterion_context = CriterionContext( + criterion_context = EngineCriterionContext( run_id=task_context.run_id, task_input=task_context.task_input, agent_reasoning=task_context.agent_reasoning, @@ -72,7 +73,7 @@ async def run_criterion() -> CriterionResult: ) criterion = spec.criterion - cr_result: CriterionResult + cr_result: CriterionOutcome runtime = DefaultCriterionRuntime( context=criterion_context, @@ -93,7 +94,7 @@ async def run_criterion() -> CriterionResult: ) if isinstance(criterion, Criterion): - eval_ctx = EvaluationContext( + eval_ctx = PublicCriterionContext.with_runtime( run_id=task_context.run_id, task_id=self.task_id, execution_id=self.execution_id, @@ -101,8 +102,8 @@ async def run_criterion() -> CriterionResult: worker_result=WorkerOutput( output=agent_reasoning, ), - sandbox_id=task_context.sandbox_id, runtime=runtime, + sandbox_id=task_context.sandbox_id, ) cr_result = await criterion.evaluate(eval_ctx) else: @@ -145,7 +146,7 @@ async def run_criterion() -> CriterionResult: self.ctx.step.run, step_name, run_criterion, - output_type=CriterionResult, + output_type=CriterionOutcome, ) return list(await self.ctx.group.parallel(tuple(make_step(spec) for spec in criteria))) diff --git a/ergon_core/ergon_core/core/runtime/evaluation/evaluation_schemas.py b/ergon_core/ergon_core/core/application/evaluation/models.py similarity index 57% rename from ergon_core/ergon_core/core/runtime/evaluation/evaluation_schemas.py rename to ergon_core/ergon_core/core/application/evaluation/models.py index 6c7c65f6..d387e120 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/evaluation_schemas.py +++ b/ergon_core/ergon_core/core/application/evaluation/models.py @@ -1,17 +1,41 @@ -"""Core schemas for the evaluation engine.""" +"""Evaluation dispatch DTOs.""" from uuid import UUID from ergon_core.api.criterion import Criterion -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject from pydantic import BaseModel, ConfigDict, Field -__all__ = [ - "CriterionContext", - "CriterionSpec", - "TaskEvaluationContext", -] +class PreparedSingleEvaluator(BaseModel): + model_config = {"frozen": True} + + evaluator_id: UUID + evaluator_binding_key: str + evaluator_type: str + task_input: str + agent_reasoning: str | None = None + agent_outputs: list[JsonObject] = Field(default_factory=list) + + +class PreparedEvaluatorDispatch(BaseModel): + model_config = {"frozen": True} + + node_id: UUID + task_id: UUID | None = None + evaluators_found: int = 0 + invalid_evaluator_ids: list[UUID] = Field(default_factory=list) + valid_evaluators: list[PreparedSingleEvaluator] = Field(default_factory=list) + + +class DispatchEvaluatorsCommand(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + definition_id: UUID + node_id: UUID + task_id: UUID | None = None + execution_id: UUID class CriterionContext(BaseModel): """Context for evaluating a single criterion within the engine.""" diff --git a/ergon_core/ergon_core/core/runtime/evaluation/protocols.py b/ergon_core/ergon_core/core/application/evaluation/protocols.py similarity index 91% rename from ergon_core/ergon_core/core/runtime/evaluation/protocols.py rename to ergon_core/ergon_core/core/application/evaluation/protocols.py index 79f2695a..a7a9f4fb 100644 --- a/ergon_core/ergon_core/core/runtime/evaluation/protocols.py +++ b/ergon_core/ergon_core/core/application/evaluation/protocols.py @@ -8,10 +8,8 @@ if TYPE_CHECKING: from sqlmodel import Session - from ergon_core.core.sandbox.event_sink import SandboxEventSink - from ergon_core.core.runtime.resources import RunResourceView - -__all__ = ["CommandResult", "CriterionRuntime", "SandboxResult"] + from ergon_core.core.infrastructure.sandbox.event_sink import SandboxEventSink + from ergon_core.core.application.resources import RunResourceView class SandboxResult(BaseModel): diff --git a/ergon_core/ergon_core/core/application/evaluation/scoring.py b/ergon_core/ergon_core/core/application/evaluation/scoring.py new file mode 100644 index 00000000..2621bb00 --- /dev/null +++ b/ergon_core/ergon_core/core/application/evaluation/scoring.py @@ -0,0 +1,32 @@ +"""Shared score aggregation semantics for run-level evaluation summaries.""" + +from collections.abc import Iterable +from typing import Protocol + +from pydantic import BaseModel + + +class ScoredEvaluation(Protocol): + score: float | None + + +class EvaluationScoreSummary(BaseModel): + model_config = {"frozen": True} + + final_score: float | None + normalized_score: float | None + evaluators_count: int + + +def aggregate_evaluation_scores( + evaluations: Iterable[ScoredEvaluation], +) -> EvaluationScoreSummary: + rows = list(evaluations) + scores = [row.score for row in rows if row.score is not None] + final_score = sum(scores) if scores else None + normalized_score = final_score / len(scores) if scores and final_score is not None else None + return EvaluationScoreSummary( + final_score=final_score, + normalized_score=normalized_score, + evaluators_count=len(rows), + ) diff --git a/ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py b/ergon_core/ergon_core/core/application/evaluation/service.py similarity index 54% rename from ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py rename to ergon_core/ergon_core/core/application/evaluation/service.py index d8101f48..c0207cbe 100644 --- a/ergon_core/ergon_core/core/runtime/services/evaluation_persistence_service.py +++ b/ergon_core/ergon_core/core/application/evaluation/service.py @@ -1,20 +1,46 @@ -"""Persistence and DTO shaping for task evaluations.""" +"""Single front-door service for task evaluation workflow.""" from uuid import UUID -from ergon_core.core.api.schemas import RunEvaluationCriterionDto, RunTaskEvaluationDto +from ergon_core.api.benchmark import Task +from ergon_core.api.criterion import CriterionOutcome +from ergon_core.api.rubric import Evaluator, TaskEvaluationResult +from ergon_core.core.persistence.definitions.models import ( + ExperimentDefinitionEvaluator, + ExperimentDefinitionTask, + ExperimentDefinitionTaskEvaluator, +) +from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.evaluation_summary import ( - CriterionResultEntry, + CriterionOutcomeEntry, EvaluationSummary, ) +from ergon_core.core.persistence.telemetry.models import RunRecord, RunTaskExecution from ergon_core.core.persistence.telemetry.repositories import ( CreateTaskEvaluation, TelemetryRepository, ) -from ergon_core.core.runtime.errors import ContractViolationError -from ergon_core.core.runtime.services.rubric_evaluation_service import EvaluationServiceResult +from ergon_core.core.application.evaluation.executors import CriterionExecutor +from ergon_core.core.application.evaluation.scoring import aggregate_evaluation_scores +from ergon_core.core.application.evaluation.models import ( + CriterionSpec, + DispatchEvaluatorsCommand, + PreparedEvaluatorDispatch, + PreparedSingleEvaluator, + TaskEvaluationContext, +) +from ergon_core.core.infrastructure.inngest.errors import ContractViolationError +from ergon_core.core.application.read_models.models import RunEvaluationCriterionDto, RunTaskEvaluationDto from pydantic import BaseModel +from sqlmodel import Session, select + + +class EvaluationServiceResult(BaseModel): + """Internal result carrying both the public evaluation + spec metadata.""" + + result: TaskEvaluationResult + specs: list[CriterionSpec] class PersistedEvaluation(BaseModel): @@ -26,12 +52,111 @@ class PersistedEvaluation(BaseModel): dashboard_dto: RunTaskEvaluationDto -class EvaluationPersistenceService: - """Persist task evaluations and produce typed dashboard DTOs.""" +class EvaluationService: + """Prepare, execute, and persist task evaluations.""" - def __init__(self, telemetry_repo: TelemetryRepository | None = None) -> None: + def __init__( + self, + criterion_executor: CriterionExecutor | None = None, + telemetry_repo: TelemetryRepository | None = None, + ) -> None: + self.criterion_executor = criterion_executor self.telemetry_repo = telemetry_repo or TelemetryRepository() + def prepare_dispatch(self, command: DispatchEvaluatorsCommand) -> PreparedEvaluatorDispatch: + session = get_session() + try: + node = session.get(RunGraphNode, command.node_id) + if node is None: + raise LookupError(f"run graph node not found: {command.node_id}") + task_id = command.task_id or node.definition_task_id + if task_id is None: + return PreparedEvaluatorDispatch( + node_id=command.node_id, + task_id=None, + evaluators_found=0, + ) + task_evals = list( + session.exec( + select(ExperimentDefinitionTaskEvaluator).where( + ExperimentDefinitionTaskEvaluator.experiment_definition_id + == command.definition_id, + ExperimentDefinitionTaskEvaluator.task_id == task_id, + ) + ).all() + ) + if not task_evals: + return PreparedEvaluatorDispatch( + node_id=command.node_id, + task_id=task_id, + evaluators_found=0, + ) + task_row = session.get(ExperimentDefinitionTask, task_id) + if task_row is None: + raise LookupError(f"definition task not found: {task_id}") + execution = session.get(RunTaskExecution, command.execution_id) + agent_reasoning = execution.final_assistant_message if execution is not None else None + valid_evaluators: list[PreparedSingleEvaluator] = [] + for te in task_evals: + evaluator_def = session.exec( + select(ExperimentDefinitionEvaluator).where( + ExperimentDefinitionEvaluator.experiment_definition_id + == command.definition_id, + ExperimentDefinitionEvaluator.binding_key == te.evaluator_binding_key, + ) + ).first() + if evaluator_def is None: + continue + valid_evaluators.append( + PreparedSingleEvaluator( + evaluator_id=evaluator_def.id, + evaluator_binding_key=te.evaluator_binding_key, + evaluator_type=evaluator_def.evaluator_type, + task_input=task_row.description, + agent_reasoning=agent_reasoning, + ) + ) + return PreparedEvaluatorDispatch( + node_id=command.node_id, + task_id=task_id, + evaluators_found=len(task_evals), + valid_evaluators=valid_evaluators, + ) + finally: + session.close() + + async def evaluate( + self, + task_context: TaskEvaluationContext, + evaluator: Evaluator, + task: Task, + benchmark_name: str, + ) -> EvaluationServiceResult: + if self.criterion_executor is None: + raise RuntimeError("EvaluationService.evaluate requires a criterion executor") + criteria = list(evaluator.criteria_for(task)) + specs = [ + CriterionSpec( + criterion=c, + criterion_idx=i, + max_score=c.score_spec.max_score, + stage_idx=0, + stage_name="default", + aggregation_weight=c.weight, + ) + for i, c in enumerate(criteria) + ] + criterion_results: list[CriterionOutcome] = await self.criterion_executor.execute_all( + task_context=task_context, + task=task, + benchmark_name=benchmark_name, + criteria=specs, + ) + return EvaluationServiceResult( + result=evaluator.aggregate_task(task, criterion_results), + specs=specs, + ) + def persist_success( self, *, @@ -61,7 +186,7 @@ def persist_success( summary_json=summary.model_dump(mode="json"), ), ) - self.telemetry_repo.refresh_run_evaluation_summary(session, run_id) + self._refresh_run_evaluation_summary(session, run_id) session.commit() session.refresh(evaluation) return PersistedEvaluation( @@ -114,11 +239,29 @@ def persist_failure( summary_json=summary.model_dump(mode="json"), ), ) - self.telemetry_repo.refresh_run_evaluation_summary(session, run_id) + self._refresh_run_evaluation_summary(session, run_id) session.commit() finally: session.close() + def _refresh_run_evaluation_summary(self, session: Session, run_id: UUID) -> None: + run = session.get(RunRecord, run_id) + if run is None: + return + evaluations = self.telemetry_repo.get_task_evaluations(session, run_id) + score_summary = aggregate_evaluation_scores(evaluations) + existing_summary = dict({} if run.summary_json is None else run.summary_json) + existing_summary.update( + { + "final_score": score_summary.final_score, + "normalized_score": score_summary.normalized_score, + "evaluators_count": score_summary.evaluators_count, + } + ) + run.summary_json = existing_summary + session.add(run) + session.flush() + def _criterion_status(*, passed: bool, error: dict | None, skipped_reason: str | None) -> str: if error is not None: @@ -138,23 +281,20 @@ def build_evaluation_summary( service_result: EvaluationServiceResult, evaluation_input: str | None, ) -> EvaluationSummary: - """Build a strongly typed evaluation summary from service result + specs.""" result = service_result.result specs = service_result.specs - spec_by_idx = {s.criterion_idx: s for s in specs} max_score_total = _summary_max_score(result, specs) - - entries: list[CriterionResultEntry] = [] + entries: list[CriterionOutcomeEntry] = [] for i, cr in enumerate(result.criterion_results): spec = spec_by_idx.get(i) if spec is None: raise ContractViolationError( f"Criterion result at index {i} ({cr.slug!r}) has no matching " - f"CriterionSpec - specs and results are out of sync", + "CriterionSpec - specs and results are out of sync", ) entries.append( - CriterionResultEntry( + CriterionOutcomeEntry( criterion_slug=cr.slug, criterion_name=cr.name, criterion_type=spec.criterion.type_slug, @@ -182,20 +322,16 @@ def build_evaluation_summary( error=cr.error, ) ) - - total_score = result.score - stage_names = {s.stage_name for s in specs} stages_passed = sum( 1 for stage_name in stage_names if all(e.passed for e in entries if e.stage_name == stage_name) ) - return EvaluationSummary( evaluator_name=result.evaluator_name, max_score=max_score_total, - normalized_score=total_score, + normalized_score=result.score, stages_evaluated=len(stage_names), stages_passed=stages_passed, metadata=result.metadata, diff --git a/ergon_core/ergon_core/core/runtime/services/evaluation_dto.py b/ergon_core/ergon_core/core/runtime/services/evaluation_dto.py deleted file mode 100644 index d5b0ff9a..00000000 --- a/ergon_core/ergon_core/core/runtime/services/evaluation_dto.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Evaluation dispatch DTOs.""" - -from uuid import UUID - -from ergon_core.core.json_types import JsonObject -from pydantic import BaseModel, Field - - -class PreparedSingleEvaluator(BaseModel): - model_config = {"frozen": True} - - evaluator_id: UUID - evaluator_binding_key: str - evaluator_type: str - task_input: str - agent_reasoning: str | None = None - agent_outputs: list[JsonObject] = Field(default_factory=list) - - -class PreparedEvaluatorDispatch(BaseModel): - model_config = {"frozen": True} - - node_id: UUID - task_id: UUID | None = None - evaluators_found: int = 0 - invalid_evaluator_ids: list[UUID] = Field(default_factory=list) - valid_evaluators: list[PreparedSingleEvaluator] = Field(default_factory=list) - - -class DispatchEvaluatorsCommand(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - definition_id: UUID - node_id: UUID - task_id: UUID | None = None - execution_id: UUID diff --git a/ergon_core/ergon_core/core/runtime/services/evaluator_dispatch_service.py b/ergon_core/ergon_core/core/runtime/services/evaluator_dispatch_service.py deleted file mode 100644 index cbf7ce48..00000000 --- a/ergon_core/ergon_core/core/runtime/services/evaluator_dispatch_service.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Prepare evaluation payloads for task-level evaluator fanout. - -Reads evaluator bindings from definition tables and task execution -outputs to build PreparedSingleEvaluator payloads. -""" - -from ergon_core.core.persistence.definitions.models import ( - ExperimentDefinitionEvaluator, - ExperimentDefinitionTask, - ExperimentDefinitionTaskEvaluator, -) -from ergon_core.core.persistence.graph.models import RunGraphNode -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.telemetry.models import RunTaskExecution -from ergon_core.core.runtime.services.evaluation_dto import ( - DispatchEvaluatorsCommand, - PreparedEvaluatorDispatch, - PreparedSingleEvaluator, -) -from sqlmodel import select - - -class EvaluatorDispatchService: - """Prepare evaluation payloads from definition rows + task execution outputs.""" - - def prepare_dispatch(self, command: DispatchEvaluatorsCommand) -> PreparedEvaluatorDispatch: - session = get_session() - try: - node = session.get(RunGraphNode, command.node_id) - if node is None: - raise LookupError(f"run graph node not found: {command.node_id}") - task_id = command.task_id or node.definition_task_id - if task_id is None: - return PreparedEvaluatorDispatch( - node_id=command.node_id, - task_id=None, - evaluators_found=0, - ) - task_evals = list( - session.exec( - select(ExperimentDefinitionTaskEvaluator).where( - ExperimentDefinitionTaskEvaluator.experiment_definition_id - == command.definition_id, - ExperimentDefinitionTaskEvaluator.task_id == task_id, - ) - ).all() - ) - - if not task_evals: - return PreparedEvaluatorDispatch( - node_id=command.node_id, - task_id=task_id, - evaluators_found=0, - ) - - task_row = session.get(ExperimentDefinitionTask, task_id) - if task_row is None: - raise LookupError(f"definition task not found: {task_id}") - - execution = session.get(RunTaskExecution, command.execution_id) - agent_reasoning = execution.final_assistant_message if execution is not None else None - - valid_evaluators: list[PreparedSingleEvaluator] = [] - for te in task_evals: - evaluator_def = session.exec( - select(ExperimentDefinitionEvaluator).where( - ExperimentDefinitionEvaluator.experiment_definition_id - == command.definition_id, - ExperimentDefinitionEvaluator.binding_key == te.evaluator_binding_key, - ) - ).first() - - if evaluator_def is None: - continue - - valid_evaluators.append( - PreparedSingleEvaluator( - evaluator_id=evaluator_def.id, - evaluator_binding_key=te.evaluator_binding_key, - evaluator_type=evaluator_def.evaluator_type, - task_input=task_row.description, - agent_reasoning=agent_reasoning, - ) - ) - - return PreparedEvaluatorDispatch( - node_id=command.node_id, - task_id=task_id, - evaluators_found=len(task_evals), - valid_evaluators=valid_evaluators, - ) - finally: - session.close() diff --git a/ergon_core/ergon_core/core/runtime/services/rubric_evaluation_service.py b/ergon_core/ergon_core/core/runtime/services/rubric_evaluation_service.py deleted file mode 100644 index 0c5b8292..00000000 --- a/ergon_core/ergon_core/core/runtime/services/rubric_evaluation_service.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Service for evaluating a task using a criterion executor + evaluator. - -Bridges between the Inngest evaluation functions and the public -Evaluator/Rubric API. Calls executor.execute_all() then evaluator.aggregate_task(). - -Returns both the public TaskEvaluationResult and the CriterionSpecs -so the persistence layer can build a fully-typed EvaluationSummary. -""" - -from ergon_core.api.evaluator import Evaluator -from ergon_core.api.results import CriterionResult, TaskEvaluationResult -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.evaluation.evaluation_schemas import ( - CriterionSpec, - TaskEvaluationContext, -) -from ergon_core.core.runtime.evaluation.executors import CriterionExecutor -from pydantic import BaseModel - - -class EvaluationServiceResult(BaseModel): - """Internal result carrying both the public evaluation + spec metadata.""" - - result: TaskEvaluationResult - specs: list[CriterionSpec] - - -class RubricEvaluationService: - """Runs evaluation: execute criteria then aggregate via the evaluator.""" - - def __init__(self, criterion_executor: CriterionExecutor): - self.criterion_executor = criterion_executor - - async def evaluate( - self, - task_context: TaskEvaluationContext, - evaluator: Evaluator, - task: BenchmarkTask, - benchmark_name: str, - ) -> EvaluationServiceResult: - criteria = list(evaluator.criteria_for(task)) - - specs = [ - CriterionSpec( - criterion=c, - criterion_idx=i, - max_score=c.score_spec.max_score, - stage_idx=0, - stage_name="default", - aggregation_weight=c.weight, - ) - for i, c in enumerate(criteria) - ] - - criterion_results: list[CriterionResult] = await self.criterion_executor.execute_all( - task_context=task_context, - task=task, - benchmark_name=benchmark_name, - criteria=specs, - ) - - task_result = evaluator.aggregate_task(task, criterion_results) - return EvaluationServiceResult(result=task_result, specs=specs) From 857f0c5c6f4033a8029cbf537c36a4e35386fd3a Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:45 +0100 Subject: [PATCH 53/66] refactor: move workflow application services Made-with: Cursor --- .../ergon_core/core/application/__init__.py | 0 .../application/communication/__init__.py | 1 + .../core/application/communication/errors.py | 1 + .../communication/models.py} | 53 ++- .../communication/service.py} | 8 +- .../core/application/context/__init__.py | 1 + .../core/application/context/events.py | 144 ++++++++ .../application/context/output_extraction.py | 41 +++ .../core/application/resources/__init__.py | 4 + .../core/application/resources/models.py | 80 +++++ .../core/application/resources/repository.py | 87 +++++ .../core/application/workflows/__init__.py | 0 .../core/application/workflows/errors.py | 6 + .../workflows/models.py} | 2 +- .../application/workflows/orchestration.py | 164 +++++++++ .../core/application/workflows/runs.py | 107 ++++++ .../workflows/service.py} | 311 ++++++++++++++++-- .../core/runtime/services/cohort_schemas.py | 77 ----- .../core/runtime/services/cohort_service.py | 275 ---------------- .../runtime/services/cohort_stats_service.py | 101 ------ .../services/workflow_finalization_service.py | 57 ---- .../workflow_initialization_service.py | 94 ------ 22 files changed, 959 insertions(+), 655 deletions(-) create mode 100644 ergon_core/ergon_core/core/application/__init__.py create mode 100644 ergon_core/ergon_core/core/application/communication/__init__.py create mode 100644 ergon_core/ergon_core/core/application/communication/errors.py rename ergon_core/ergon_core/core/{runtime/services/communication_schemas.py => application/communication/models.py} (54%) rename ergon_core/ergon_core/core/{runtime/services/communication_service.py => application/communication/service.py} (97%) create mode 100644 ergon_core/ergon_core/core/application/context/__init__.py create mode 100644 ergon_core/ergon_core/core/application/context/events.py create mode 100644 ergon_core/ergon_core/core/application/context/output_extraction.py create mode 100644 ergon_core/ergon_core/core/application/resources/__init__.py create mode 100644 ergon_core/ergon_core/core/application/resources/models.py create mode 100644 ergon_core/ergon_core/core/application/resources/repository.py create mode 100644 ergon_core/ergon_core/core/application/workflows/__init__.py create mode 100644 ergon_core/ergon_core/core/application/workflows/errors.py rename ergon_core/ergon_core/core/{runtime/services/workflow_dto.py => application/workflows/models.py} (96%) create mode 100644 ergon_core/ergon_core/core/application/workflows/orchestration.py create mode 100644 ergon_core/ergon_core/core/application/workflows/runs.py rename ergon_core/ergon_core/core/{runtime/services/workflow_service.py => application/workflows/service.py} (69%) delete mode 100644 ergon_core/ergon_core/core/runtime/services/cohort_schemas.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/cohort_service.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/cohort_stats_service.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/workflow_finalization_service.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py diff --git a/ergon_core/ergon_core/core/application/__init__.py b/ergon_core/ergon_core/core/application/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ergon_core/ergon_core/core/application/communication/__init__.py b/ergon_core/ergon_core/core/application/communication/__init__.py new file mode 100644 index 00000000..a6728019 --- /dev/null +++ b/ergon_core/ergon_core/core/application/communication/__init__.py @@ -0,0 +1 @@ +"""Application communication services and DTOs.""" diff --git a/ergon_core/ergon_core/core/application/communication/errors.py b/ergon_core/ergon_core/core/application/communication/errors.py new file mode 100644 index 00000000..31b650fa --- /dev/null +++ b/ergon_core/ergon_core/core/application/communication/errors.py @@ -0,0 +1 @@ +"""Communication application errors.""" diff --git a/ergon_core/ergon_core/core/runtime/services/communication_schemas.py b/ergon_core/ergon_core/core/application/communication/models.py similarity index 54% rename from ergon_core/ergon_core/core/runtime/services/communication_schemas.py rename to ergon_core/ergon_core/core/application/communication/models.py index d16e6f77..30d3f38c 100644 --- a/ergon_core/ergon_core/core/runtime/services/communication_schemas.py +++ b/ergon_core/ergon_core/core/application/communication/models.py @@ -1,13 +1,51 @@ -"""Pydantic DTOs for the inter-agent communication service.""" +"""Pydantic DTOs for inter-agent communication services and read models.""" from datetime import datetime from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field -# --------------------------------------------------------------------------- -# Requests -# --------------------------------------------------------------------------- + +def _to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +class CamelModel(BaseModel): + """Base model that exposes camelCase JSON to the frontend.""" + + model_config = ConfigDict( + alias_generator=_to_camel, + populate_by_name=True, + extra="forbid", + ) + + +class RunCommunicationMessageDto(CamelModel): + id: str + thread_id: str + thread_topic: str + run_id: str + task_id: str | None = None + task_execution_id: str | None = None + from_agent_id: str + to_agent_id: str + content: str + sequence_num: int + created_at: datetime + + +class RunCommunicationThreadDto(CamelModel): + id: str + run_id: str + task_id: str | None = None + topic: str + summary: str | None = None + agent_a_id: str + agent_b_id: str + created_at: datetime + updated_at: datetime + messages: list[RunCommunicationMessageDto] = Field(default_factory=list) class CreateMessageRequest(BaseModel): @@ -27,11 +65,6 @@ class CreateMessageRequest(BaseModel): task_execution_id: UUID | None = None -# --------------------------------------------------------------------------- -# Responses -# --------------------------------------------------------------------------- - - class MessageResponse(BaseModel): message_id: UUID thread_id: UUID diff --git a/ergon_core/ergon_core/core/runtime/services/communication_service.py b/ergon_core/ergon_core/core/application/communication/service.py similarity index 97% rename from ergon_core/ergon_core/core/runtime/services/communication_service.py rename to ergon_core/ergon_core/core/application/communication/service.py index fc0167f6..d8e06c9a 100644 --- a/ergon_core/ergon_core/core/runtime/services/communication_service.py +++ b/ergon_core/ergon_core/core/application/communication/service.py @@ -3,20 +3,20 @@ import logging from uuid import UUID -from ergon_core.core.api.schemas import ( +from ergon_core.core.application.communication.models import ( RunCommunicationMessageDto, RunCommunicationThreadDto, ) -from ergon_core.core.dashboard.provider import get_dashboard_emitter +from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import Thread, ThreadMessage -from ergon_core.core.runtime.services.communication_schemas import ( +from ergon_core.core.application.communication.models import ( CreateMessageRequest, MessageResponse, ThreadSummary, ThreadWithMessages, ) -from ergon_core.core.utils import utcnow +from ergon_core.core.shared.utils import utcnow from sqlalchemy.exc import IntegrityError from sqlmodel import func, select diff --git a/ergon_core/ergon_core/core/application/context/__init__.py b/ergon_core/ergon_core/core/application/context/__init__.py new file mode 100644 index 00000000..b71f5b72 --- /dev/null +++ b/ergon_core/ergon_core/core/application/context/__init__.py @@ -0,0 +1 @@ +"""Application context event services.""" diff --git a/ergon_core/ergon_core/core/application/context/events.py b/ergon_core/ergon_core/core/application/context/events.py new file mode 100644 index 00000000..8e9c290c --- /dev/null +++ b/ergon_core/ergon_core/core/application/context/events.py @@ -0,0 +1,144 @@ +"""Application service for append-only worker context events. + +The service maintains per-execution sequence counters in memory. This is safe +because each execution runs in a single Inngest invocation. +""" + +import logging +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from ergon_core.core.domain.generation.context_parts import ( + AssistantTextPart, + ContextPartChunk, + ContextPartChunkLog, + SystemPromptPart, + ThinkingPart, + ToolCallPart, + ToolResultPart, + UserMessagePart, +) +from ergon_core.core.persistence.context.models import RunContextEvent +from sqlmodel import Session, select + +logger = logging.getLogger(__name__) + + +class ContextEventService: + """Append-only write and read path for ``run_context_events``.""" + + def __init__(self) -> None: + self._listeners: list[Callable[[RunContextEvent], Awaitable[None]]] = [] + self._sequence_counters: dict[UUID, int] = {} + self._active_turn_ids: dict[UUID, str] = {} + + def add_listener(self, listener: Callable[[RunContextEvent], Awaitable[None]]) -> None: + self._listeners.append(listener) + + def _next_sequence(self, execution_id: UUID) -> int: + return self._sequence_counters.get(execution_id, 0) + + def _make_event( + self, + run_id: UUID, + execution_id: UUID, + worker_binding_key: str, + sequence: int, + payload: ContextPartChunkLog, + *, + started_at: datetime | None = None, + completed_at: datetime | None = None, + policy_version: str | None = None, + ) -> RunContextEvent: + return RunContextEvent( + run_id=run_id, + task_execution_id=execution_id, + worker_binding_key=worker_binding_key, + sequence=sequence, + event_type=payload.part.part_kind, + payload=payload.model_dump(mode="json"), + started_at=started_at, + completed_at=completed_at, + policy_version=policy_version, + ) + + def _turn_id_for_chunk(self, execution_id: UUID, chunk: ContextPartChunk) -> str | None: + part = chunk.part + if isinstance(part, (AssistantTextPart, ThinkingPart, ToolCallPart)): + turn_id = self._active_turn_ids.get(execution_id) + if turn_id is None: + turn_id = str(uuid4()) + self._active_turn_ids[execution_id] = turn_id + return turn_id + if isinstance(part, (SystemPromptPart, UserMessagePart, ToolResultPart)): + self._active_turn_ids.pop(execution_id, None) + return None + return None + + async def persist_chunk( + self, + session: Session, + *, + run_id: UUID, + execution_id: UUID, + worker_binding_key: str, + chunk: ContextPartChunk, + started_at: datetime | None = None, + completed_at: datetime | None = None, + policy_version: str | None = None, + ) -> RunContextEvent: + """Enrich and persist one worker-emitted context stream chunk.""" + seq = self._next_sequence(execution_id) + now = datetime.now(UTC) + event_started_at = started_at or now + event_completed_at = completed_at or now + payload = ContextPartChunkLog( + part=chunk.part, + token_ids=chunk.token_ids, + logprobs=chunk.logprobs, + sequence=seq, + worker_binding_key=worker_binding_key, + turn_id=self._turn_id_for_chunk(execution_id, chunk), + started_at=event_started_at, + completed_at=event_completed_at, + policy_version=policy_version, + ) + event = self._make_event( + run_id, + execution_id, + worker_binding_key, + seq, + payload, + started_at=payload.started_at, + completed_at=payload.completed_at, + policy_version=payload.policy_version, + ) + self._sequence_counters[execution_id] = seq + 1 + + session.add(event) + session.commit() + + for listener in self._listeners: + try: + await listener(event) + except Exception: # slopcop: ignore[no-broad-except] + logger.warning("Context event listener failed", exc_info=True) + + return event + + def get_for_execution(self, session: Session, execution_id: UUID) -> list[RunContextEvent]: + stmt = ( + select(RunContextEvent) + .where(RunContextEvent.task_execution_id == execution_id) + .order_by(RunContextEvent.sequence) + ) + return list(session.exec(stmt).all()) + + def get_for_run(self, session: Session, run_id: UUID) -> list[RunContextEvent]: + stmt = ( + select(RunContextEvent) + .where(RunContextEvent.run_id == run_id) + .order_by(RunContextEvent.task_execution_id, RunContextEvent.sequence) + ) + return list(session.exec(stmt).all()) diff --git a/ergon_core/ergon_core/core/application/context/output_extraction.py b/ergon_core/ergon_core/core/application/context/output_extraction.py new file mode 100644 index 00000000..4cf5db52 --- /dev/null +++ b/ergon_core/ergon_core/core/application/context/output_extraction.py @@ -0,0 +1,41 @@ +"""Helpers for extracting worker outputs from persisted context events.""" + +from collections.abc import Iterable +from typing import Any +from uuid import UUID + +from ergon_core.api.worker.context import WorkerContext +from ergon_core.api.worker.results import WorkerOutput +from ergon_core.core.domain.generation.context_parts import AssistantTextPart +from ergon_core.core.persistence.context.models import RunContextEvent +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.application.context.events import ContextEventService + + +def extract_assistant_text(events: Iterable[RunContextEvent]) -> str: + """Return the last assistant text part from context events in iteration order.""" + text_events: list[str] = [] + for event in events: + if event.event_type != "assistant_text": + continue + payload = event.parsed_payload() + if isinstance(payload.part, AssistantTextPart): + text_events.append(payload.part.content) + return text_events[-1] if text_events else "" + + +def get_output(session: Any, execution_id: UUID) -> str: # slopcop: ignore[no-typing-any] + """Return assistant text output persisted for a worker execution.""" + events = ContextEventService().get_for_execution(session, execution_id) + return extract_assistant_text(events) + + +def default_worker_output(context: WorkerContext) -> WorkerOutput: + """Return the last assistant text persisted for a worker execution.""" + with get_session() as session: + output = get_output(session, context.execution_id) + + return WorkerOutput( + output=output, + success=True, + ) diff --git a/ergon_core/ergon_core/core/application/resources/__init__.py b/ergon_core/ergon_core/core/application/resources/__init__.py new file mode 100644 index 00000000..2b49dd34 --- /dev/null +++ b/ergon_core/ergon_core/core/application/resources/__init__.py @@ -0,0 +1,4 @@ +from ergon_core.core.application.resources.models import RunResourceView +from ergon_core.core.application.resources.repository import RunResourceRepository + +__all__ = ["RunResourceRepository", "RunResourceView"] diff --git a/ergon_core/ergon_core/core/application/resources/models.py b/ergon_core/ergon_core/core/application/resources/models.py new file mode 100644 index 00000000..51af3239 --- /dev/null +++ b/ergon_core/ergon_core/core/application/resources/models.py @@ -0,0 +1,80 @@ +"""Resource DTOs.""" + +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import UUID + +from ergon_core.core.persistence.shared.enums import RunResourceKind +from ergon_core.core.shared.json_types import JsonObject +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + from ergon_core.core.persistence.telemetry.models import RunResource as _RunResourceRow + + +class RunResourceView(BaseModel): + """Read-only DTO for a ``run_resources`` row. + + Construct via ``RunResourceView.from_row(orm_row)``. + """ + + model_config = ConfigDict(frozen=True) + + id: UUID = Field(description="Primary key of the run_resources row.") + run_id: UUID = Field(description="The run this resource was produced in.") + task_execution_id: UUID | None = Field( + description=( + "The task execution that produced the resource, or ``None`` for " + "run-scoped resources (e.g. aggregate reports)." + ), + ) + kind: RunResourceKind = Field( + description="Canonical category (report, worker_output, trace, etc.).", + ) + name: str = Field( + description="Human-readable name -- usually the sandbox file name or the output slot.", + ) + mime_type: str = Field( + description="Best-effort MIME type, guessed from ``name`` if not provided.", + ) + file_path: str = Field( + description=( + "Absolute path to the content-addressed blob on disk " + "(``${ERGON_BLOB_ROOT}//``)." + ), + ) + size_bytes: int = Field(description="Size of the blob in bytes.") + content_hash: str | None = Field( + description="SHA-256 hex digest of the blob; used for dedup and verification.", + ) + error: str | None = Field( + description="Populated only when writing the resource failed; ``None`` on success.", + ) + metadata: JsonObject = Field( + default_factory=dict, + description='Free-form publisher metadata (e.g. ``{"sandbox_origin": "..."}``).', + ) + created_at: datetime = Field( + description=( + "Row insertion time; the log is append-only, so ``(created_at, id)`` " + "DESC defines 'latest' for a given file_path." + ), + ) + + @classmethod + def from_row(cls, row: "_RunResourceRow") -> "RunResourceView": + """Map an ORM ``RunResource`` row to a frozen DTO.""" + return cls( + id=row.id, + run_id=row.run_id, + task_execution_id=row.task_execution_id, + kind=RunResourceKind(row.kind), + name=row.name, + mime_type=row.mime_type, + file_path=row.file_path, + size_bytes=row.size_bytes, + content_hash=row.content_hash, + error=row.error, + metadata=row.metadata_json, + created_at=row.created_at, + ) diff --git a/ergon_core/ergon_core/core/application/resources/repository.py b/ergon_core/ergon_core/core/application/resources/repository.py new file mode 100644 index 00000000..65abdb0d --- /dev/null +++ b/ergon_core/ergon_core/core/application/resources/repository.py @@ -0,0 +1,87 @@ +"""Resource repository.""" + +from uuid import UUID + +from ergon_core.core.persistence.telemetry.models import RunResource +from ergon_core.core.shared.json_types import JsonObject +from sqlmodel import Session, select + + +class RunResourceRepository: + """Domain repository for append-only run resource rows.""" + + def list_by_run(self, session: Session, run_id: UUID) -> list[RunResource]: + stmt = select(RunResource).where(RunResource.run_id == run_id) + return list(session.exec(stmt).all()) + + def list_by_execution(self, session: Session, task_execution_id: UUID) -> list[RunResource]: + stmt = select(RunResource).where(RunResource.task_execution_id == task_execution_id) + return list(session.exec(stmt).all()) + + def latest_by_path( + self, + session: Session, + *, + task_execution_id: UUID, + file_path: str, + ) -> RunResource | None: + stmt = ( + select(RunResource) + .where( + RunResource.task_execution_id == task_execution_id, + RunResource.file_path == file_path, + ) + .order_by(RunResource.created_at.desc(), RunResource.id.desc()) + .limit(1) + ) + return session.exec(stmt).first() + + def find_by_hash( + self, + session: Session, + *, + task_execution_id: UUID, + content_hash: str, + ) -> RunResource | None: + stmt = ( + select(RunResource) + .where( + RunResource.task_execution_id == task_execution_id, + RunResource.content_hash == content_hash, + ) + .limit(1) + ) + return session.exec(stmt).first() + + def append( # slopcop: ignore[max-function-params] + self, + session: Session, + *, + run_id: UUID, + task_execution_id: UUID, + kind: str, + name: str, + mime_type: str, + file_path: str, + size_bytes: int, + error: str | None, + content_hash: str | None, + metadata: JsonObject | None = None, + copied_from_resource_id: UUID | None = None, + ) -> RunResource: + row = RunResource( + run_id=run_id, + task_execution_id=task_execution_id, + kind=kind, + name=name, + mime_type=mime_type, + file_path=file_path, + size_bytes=size_bytes, + error=error, + content_hash=content_hash, + metadata_json=metadata or {}, + copied_from_resource_id=copied_from_resource_id, + ) + session.add(row) + session.flush() + return row diff --git a/ergon_core/ergon_core/core/application/workflows/__init__.py b/ergon_core/ergon_core/core/application/workflows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ergon_core/ergon_core/core/application/workflows/errors.py b/ergon_core/ergon_core/core/application/workflows/errors.py new file mode 100644 index 00000000..6b110c67 --- /dev/null +++ b/ergon_core/ergon_core/core/application/workflows/errors.py @@ -0,0 +1,6 @@ +"""Workflow-domain errors.""" + + +class WorkflowError(Exception): + """Base for workflow-domain failures.""" + diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_dto.py b/ergon_core/ergon_core/core/application/workflows/models.py similarity index 96% rename from ergon_core/ergon_core/core/runtime/services/workflow_dto.py rename to ergon_core/ergon_core/core/application/workflows/models.py index 59a7335a..dcef5914 100644 --- a/ergon_core/ergon_core/core/runtime/services/workflow_dto.py +++ b/ergon_core/ergon_core/core/application/workflows/models.py @@ -1,7 +1,7 @@ from datetime import datetime from uuid import UUID -from ergon_core.core.runtime.services.graph_dto import GraphTaskRef as WorkflowTaskRef +from ergon_core.core.application.graph.models import GraphTaskRef as WorkflowTaskRef from pydantic import BaseModel, Field diff --git a/ergon_core/ergon_core/core/application/workflows/orchestration.py b/ergon_core/ergon_core/core/application/workflows/orchestration.py new file mode 100644 index 00000000..812b98b4 --- /dev/null +++ b/ergon_core/ergon_core/core/application/workflows/orchestration.py @@ -0,0 +1,164 @@ +"""Typed command/result DTOs for orchestration services. + +These are the contracts between Inngest functions and services. +Adapted from ref: definition_id replaces experiment_id. +""" + +import sys +from datetime import datetime + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + pass + + +from uuid import UUID + +from ergon_core.core.shared.json_types import JsonObject +from pydantic import BaseModel, Field + + +class TaskDescriptor(BaseModel): + """Lightweight task reference for orchestration steps.""" + + model_config = {"frozen": True} + + task_id: UUID | None = None + task_slug: str + parent_task_id: UUID | None = None + node_id: UUID | None = None + + +class InitializeWorkflowCommand(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + definition_id: UUID + + +class InitializedWorkflow(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + definition_id: UUID + benchmark_type: str + total_tasks: int + total_root_tasks: int + pending_tasks: list[TaskDescriptor] = Field(default_factory=list) + initial_ready_tasks: list[TaskDescriptor] = Field(default_factory=list) + + +class PrepareTaskExecutionCommand(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + definition_id: UUID + task_id: UUID | None + node_id: UUID | None = None + + +class PreparedTaskExecution(BaseModel): + """Output of ``TaskExecutionService.prepare``. + + ``node_id`` is the runtime task identity (= ``RunGraphNode.id``); + always non-null because every task execution is attached to a + graph node. ``definition_task_id`` is the optional FK to the + static ``ExperimentDefinitionTask`` row — null for dynamically + spawned subtasks which have no compile-time declaration. This + split replaces the earlier ``task_id: UUID`` field, which was dead + downstream (never read) but required a non-null value that + dynamic-subtask preparation could not provide, crashing on the + Pydantic boundary. See docs/bugs/open/2026-04-23-inngest-function-failures.md § A. + """ + + model_config = {"frozen": True} + + run_id: UUID + definition_id: UUID + node_id: UUID + definition_task_id: UUID | None = None + task_slug: str + task_description: str + benchmark_type: str + assigned_worker_slug: str + worker_type: str + model_target: str + execution_id: UUID + skipped: bool = False + skip_reason: str | None = None + + +class FinalizeTaskExecutionCommand(BaseModel): + model_config = {"frozen": True} + + execution_id: UUID + final_assistant_message: str | None = None + output_resource_ids: list[UUID] = Field(default_factory=list) + + +class FailTaskExecutionCommand(BaseModel): + model_config = {"frozen": True} + + execution_id: UUID + run_id: UUID + task_id: UUID | None + error_message: str + error_json: JsonObject | None = None + + +class WorkflowTerminalState(StrEnum): + NONE = "none" + COMPLETED = "completed" + FAILED = "failed" + + +class PropagateTaskCompletionCommand(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + definition_id: UUID + task_id: UUID | None + execution_id: UUID + node_id: UUID | None = None + + +class PropagationResult(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + definition_id: UUID + completed_task_id: UUID | None + ready_tasks: list[TaskDescriptor] = Field(default_factory=list) + workflow_terminal_state: WorkflowTerminalState = WorkflowTerminalState.NONE + + +class FinalizeWorkflowCommand(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + definition_id: UUID + + +class FinalizedWorkflowResult(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + final_score: float | None = None + normalized_score: float | None = None + evaluators_count: int = 0 + + +class RunCompletionData(BaseModel): + """Atomic bundle passed into run completion persistence.""" + + model_config = {"frozen": True} + + completed_at: datetime + final_score: float | None = None + normalized_score: float | None = None + total_cost_usd: float = 0.0 + execution_result: JsonObject = Field(default_factory=dict) diff --git a/ergon_core/ergon_core/core/application/workflows/runs.py b/ergon_core/ergon_core/core/application/workflows/runs.py new file mode 100644 index 00000000..12a3287f --- /dev/null +++ b/ergon_core/ergon_core/core/application/workflows/runs.py @@ -0,0 +1,107 @@ +"""Run creation, dispatch, and cancellation via Inngest.""" + +import logging +from uuid import UUID + +import inngest +from ergon_core.core.domain.experiments import DefinitionHandle +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.persistence.shared.enums import TERMINAL_RUN_STATUSES, RunStatus +from ergon_core.core.persistence.telemetry.models import RunRecord +from ergon_core.core.application.events.infrastructure_events import ( + RunCancelledEvent, + RunCleanupEvent, +) +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.shared.settings import settings +from ergon_core.core.shared.utils import utcnow + +logger = logging.getLogger(__name__) + + +def _checkpoint_metadata() -> JsonObject: + """Checkpoint context for ``RunRecord.summary_json`` (eval watcher / checkpoint subprocess). + + Values come from ``Settings`` (``.env`` + process env), including ``ERGON_CHECKPOINT_*`` + set by the eval runner when spawning evaluation. + """ + if settings.checkpoint_step is None: + return {} + return { + "checkpoint_step": settings.checkpoint_step, + "checkpoint_path": settings.checkpoint_path, + } + + +def create_run( # slopcop: ignore[max-function-params] -- service boundary mirrors RunRecord provenance fields + definition: DefinitionHandle, + *, + experiment_id: UUID, + workflow_definition_id: UUID, + instance_key: str, + worker_team_json: JsonObject, + evaluator_slug: str | None = None, + model_target: str | None = None, + sandbox_slug: str | None = None, + dependency_extras_json: JsonObject | None = None, + assignment_json: JsonObject | None = None, + seed: int | None = None, +) -> RunRecord: + with get_session() as session: + run = RunRecord( + experiment_id=experiment_id, + workflow_definition_id=workflow_definition_id, + benchmark_type=definition.benchmark_type, + instance_key=instance_key, + worker_team_json=worker_team_json, + evaluator_slug=evaluator_slug, + model_target=model_target, + sandbox_slug=sandbox_slug, + dependency_extras_json=dependency_extras_json or {}, + assignment_json=assignment_json or {}, + seed=seed, + status=RunStatus.PENDING, + created_at=utcnow(), + summary_json=_checkpoint_metadata(), + ) + session.add(run) + session.commit() + session.refresh(run) + return run + + +def cancel_run(run_id: UUID) -> RunRecord: + """Cancel a run: mark CANCELLED in PG, kill Inngest functions, trigger cleanup.""" + with get_session() as session: + run = session.get(RunRecord, run_id) + if run is None: + raise ValueError(f"Run {run_id} not found") + if run.status in TERMINAL_RUN_STATUSES: + raise ValueError(f"Run {run_id} is already in terminal state: {run.status}") + + run.status = RunStatus.CANCELLED + run.completed_at = utcnow() + session.add(run) + session.commit() + session.refresh(run) + + inngest_client.send_sync( + inngest.Event( + name=RunCancelledEvent.name, + data=RunCancelledEvent(run_id=run_id).model_dump(mode="json"), + ) + ) + + inngest_client.send_sync( + inngest.Event( + name=RunCleanupEvent.name, + data=RunCleanupEvent( + run_id=run_id, + status="cancelled", + ).model_dump(mode="json"), + ) + ) + + logger.info("Cancelled run %s and dispatched cleanup", run_id) + return run diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_service.py b/ergon_core/ergon_core/core/application/workflows/service.py similarity index 69% rename from ergon_core/ergon_core/core/runtime/services/workflow_service.py rename to ergon_core/ergon_core/core/application/workflows/service.py index d86d4393..2d24761e 100644 --- a/ergon_core/ergon_core/core/runtime/services/workflow_service.py +++ b/ergon_core/ergon_core/core/application/workflows/service.py @@ -4,19 +4,47 @@ from uuid import UUID, uuid4 import inngest +from ergon_core.api.registry import registry +from ergon_core.core.persistence.definitions.models import ( + ExperimentDefinition, + ExperimentDefinitionTask, +) +from ergon_core.core.persistence.graph import status_conventions as graph_status from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode -from ergon_core.core.persistence.shared.enums import RunResourceKind, TaskExecutionStatus +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.persistence.shared.enums import RunResourceKind, RunStatus, TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import ( RunRecord, RunResource, + RunTaskEvaluation, RunTaskExecution, ) -from ergon_core.core.sandbox.manager import BaseSandboxManager, DefaultSandboxManager -from ergon_core.core.runtime.events.task_events import TaskReadyEvent -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.graph_dto import GraphEdgeDto, GraphNodeDto, MutationMeta -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.workflow_dto import ( +from ergon_core.core.application.evaluation.scoring import aggregate_evaluation_scores +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager, DefaultSandboxManager +from ergon_core.core.application.events.task_events import TaskReadyEvent +from ergon_core.core.application.graph.lookup import GraphNodeLookup +from ergon_core.core.application.graph.propagation import ( + get_initial_ready_tasks, + is_workflow_complete_v2, + is_workflow_failed_v2, + on_task_completed_or_failed, +) +from ergon_core.core.application.graph.traversal import descendant_ids +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.application.graph.models import GraphEdgeDto, GraphNodeDto, MutationMeta +from ergon_core.core.application.graph.repository import WorkflowGraphRepository +from ergon_core.core.application.workflows.orchestration import ( + FinalizedWorkflowResult, + FinalizeWorkflowCommand, + InitializedWorkflow, + InitializeWorkflowCommand, + PropagateTaskCompletionCommand, + PropagationResult, + RunCompletionData, + TaskDescriptor, + WorkflowTerminalState, +) +from ergon_core.core.application.workflows.models import ( WorkflowBlockerRef, WorkflowDependencyRef, WorkflowExecutionRef, @@ -28,6 +56,8 @@ WorkflowTaskRef, WorkflowTaskWorkspaceRef, ) +from ergon_core.core.application.tasks.repository import TaskExecutionRepository +from ergon_core.core.shared.utils import require_not_none, utcnow from sqlmodel import Session, col, select ResourceScope = Literal["input", "upstream", "own", "children", "descendants", "visible"] @@ -51,8 +81,243 @@ def __init__( ) -> None: self._sandbox_manager_factory = sandbox_manager_factory or self._sandbox_manager_for self._graph_repo = graph_repository or WorkflowGraphRepository() + self._task_execution_repo = TaskExecutionRepository() self._task_ready_dispatcher = task_ready_dispatcher or self._dispatch_task_ready + async def initialize(self, command: InitializeWorkflowCommand) -> InitializedWorkflow: + """Load a definition, seed graph state, and return initially ready tasks.""" + with get_session() as session: + definition = require_not_none( + session.get(ExperimentDefinition, command.definition_id), + f"Definition {command.definition_id} not found", + ) + benchmark_cls = require_not_none( + registry.benchmarks.get(definition.benchmark_type), + f"Benchmark {definition.benchmark_type!r} not found", + ) + all_tasks = list( + session.exec( + select(ExperimentDefinitionTask).where( + ExperimentDefinitionTask.experiment_definition_id + == command.definition_id, + ) + ).all() + ) + + self._graph_repo.initialize_from_definition( + session, + command.run_id, + command.definition_id, + initial_node_status=graph_status.PENDING, + initial_edge_status=graph_status.EDGE_PENDING, + task_payload_model=benchmark_cls.task_payload_model, + meta=MutationMeta(actor="system:workflow_init"), + ) + session.commit() + + graph_lookup = GraphNodeLookup(session, command.run_id) + task_descriptors = [ + TaskDescriptor( + task_id=t.id, + task_slug=t.task_slug, + parent_task_id=t.parent_task_id, + node_id=graph_lookup.node_id(t.id), + ) + for t in all_tasks + ] + + run_record = require_not_none( + session.get(RunRecord, command.run_id), + f"RunRecord {command.run_id} not found", + ) + run_record.status = RunStatus.EXECUTING + run_record.started_at = utcnow() + session.add(run_record) + session.commit() + + ready_ids = await get_initial_ready_tasks( + session, + command.run_id, + command.definition_id, + graph_repo=self._graph_repo, + graph_lookup=graph_lookup, + ) + ready_id_set = set(ready_ids) + root_count = sum(1 for t in all_tasks if t.parent_task_id is None) + + return InitializedWorkflow( + run_id=command.run_id, + definition_id=command.definition_id, + benchmark_type=definition.benchmark_type, + total_tasks=len(all_tasks), + total_root_tasks=root_count, + pending_tasks=task_descriptors, + initial_ready_tasks=[td for td in task_descriptors if td.task_id in ready_id_set], + ) + + def finalize(self, command: FinalizeWorkflowCommand) -> FinalizedWorkflowResult: + """Aggregate evaluations and close the run.""" + with get_session() as session: + evaluations = list( + session.exec( + select(RunTaskEvaluation).where(RunTaskEvaluation.run_id == command.run_id) + ).all() + ) + score_summary = aggregate_evaluation_scores(evaluations) + completion = RunCompletionData( + completed_at=utcnow(), + final_score=score_summary.final_score, + normalized_score=score_summary.normalized_score, + ) + run_record = require_not_none( + session.get(RunRecord, command.run_id), + f"RunRecord {command.run_id} not found", + ) + run_record.status = RunStatus.COMPLETED + run_record.completed_at = completion.completed_at + run_record.summary_json = { + "final_score": completion.final_score, + "normalized_score": completion.normalized_score, + "evaluators_count": score_summary.evaluators_count, + "total_cost_usd": completion.total_cost_usd, + } + session.add(run_record) + session.commit() + + return FinalizedWorkflowResult( + run_id=command.run_id, + final_score=score_summary.final_score, + normalized_score=score_summary.normalized_score, + evaluators_count=score_summary.evaluators_count, + ) + + async def propagate(self, command: PropagateTaskCompletionCommand) -> PropagationResult: + """Handle successful task completion and schedule newly ready tasks.""" + with get_session() as session: + node_id = command.node_id + if node_id is None: + graph_lookup = GraphNodeLookup(session, command.run_id) + node_id = graph_lookup.node_id(command.task_id) + if node_id is None: + return PropagationResult( + run_id=command.run_id, + definition_id=command.definition_id, + completed_task_id=command.task_id, + workflow_terminal_state=WorkflowTerminalState.NONE, + ) + + await self._graph_repo.update_node_status( + session, + run_id=command.run_id, + node_id=node_id, + new_status=graph_status.COMPLETED, + meta=MutationMeta( + actor="system:propagation", + reason=f"task {command.task_id} completed", + ), + only_if_not_terminal=True, + ) + newly_ready_node_ids = await on_task_completed_or_failed( + session, + command.run_id, + node_id, + graph_status.COMPLETED, + graph_repo=self._graph_repo, + ) + ready_descriptors = self._task_descriptors_for_nodes(session, newly_ready_node_ids) + terminal = WorkflowTerminalState.NONE + if is_workflow_complete_v2(session, command.run_id): + terminal = WorkflowTerminalState.COMPLETED + elif is_workflow_failed_v2(session, command.run_id): + terminal = WorkflowTerminalState.FAILED + + return PropagationResult( + run_id=command.run_id, + definition_id=command.definition_id, + completed_task_id=command.task_id, + ready_tasks=ready_descriptors, + workflow_terminal_state=terminal, + ) + + async def propagate_failure(self, command: PropagateTaskCompletionCommand) -> PropagationResult: + """Handle task failure, block successors, and detect workflow terminal state.""" + with get_session() as session: + node_id = command.node_id + if node_id is None: + graph_lookup = GraphNodeLookup(session, command.run_id) + node_id = graph_lookup.node_id(command.task_id) + + if node_id is not None: + await self._graph_repo.update_node_status( + session, + run_id=command.run_id, + node_id=node_id, + new_status=graph_status.FAILED, + meta=MutationMeta( + actor="system:propagation", + reason=f"task {command.task_id} failed", + ), + only_if_not_terminal=True, + ) + await on_task_completed_or_failed( + session, + command.run_id, + node_id, + graph_status.FAILED, + graph_repo=self._graph_repo, + ) + + terminal = WorkflowTerminalState.NONE + if is_workflow_failed_v2(session, command.run_id): + terminal = WorkflowTerminalState.FAILED + + return PropagationResult( + run_id=command.run_id, + definition_id=command.definition_id, + completed_task_id=command.task_id, + workflow_terminal_state=terminal, + ) + + async def operator_unblock(self, *, run_id: UUID, node_id: UUID, reason: str) -> None: + with get_session() as session: + await self._graph_repo.update_node_status( + session, + run_id=run_id, + node_id=node_id, + new_status=graph_status.PENDING, + meta=MutationMeta(actor="operator:unblock", reason=reason), + ) + session.commit() + + async def restart_node(self, *, run_id: UUID, node_id: UUID, reason: str) -> None: + with get_session() as session: + await self._graph_repo.update_node_status( + session, + run_id=run_id, + node_id=node_id, + new_status=graph_status.PENDING, + meta=MutationMeta(actor="operator:restart", reason=reason), + ) + session.commit() + + @staticmethod + def _task_descriptors_for_nodes( + session: Session, + node_ids: list[UUID], + ) -> list[TaskDescriptor]: + descriptors: list[TaskDescriptor] = [] + for node_id in node_ids: + node = session.get(RunGraphNode, node_id) + if node is not None: + descriptors.append( + TaskDescriptor( + task_id=node.definition_task_id, + task_slug=node.task_slug, + node_id=node_id, + ) + ) + return descriptors + def list_tasks( self, session: Session, @@ -84,16 +349,7 @@ def get_latest_execution( *, node_id: UUID, ) -> RunTaskExecution | None: - stmt = ( - select(RunTaskExecution) - .where(RunTaskExecution.node_id == node_id) - .order_by( - col(RunTaskExecution.attempt_number).desc(), - col(RunTaskExecution.started_at).desc(), - ) - .limit(1) - ) - return session.exec(stmt).first() + return self._task_execution_repo.latest_for_node(session, node_id) def list_dependencies( self, @@ -281,11 +537,7 @@ async def add_task( assigned_worker_slug: str, dry_run: bool, ) -> WorkflowMutationRef: - from ergon_builtins.registry import ( # slopcop: ignore[guarded-function-import] -- reason: workflow mutation validates plugin worker slugs only when CLI tools run - WORKERS, - ) - - if assigned_worker_slug not in WORKERS: + if assigned_worker_slug not in registry.workers: raise ValueError(f"Unknown worker slug: {assigned_worker_slug!r}") parent = self._resolve_node( session, @@ -738,20 +990,7 @@ def _descendant_ids( node_id: UUID, max_depth: int, ) -> set[UUID]: - result: set[UUID] = set() - frontier = {node_id} - for _ in range(max_depth): - children = session.exec( - select(RunGraphNode).where( - RunGraphNode.run_id == run_id, - col(RunGraphNode.parent_node_id).in_(frontier), - ) - ).all() - frontier = {child.id for child in children} - result.update(frontier) - if not frontier: - break - return result + return descendant_ids(session, run_id=run_id, root_node_id=node_id, max_depth=max_depth) @staticmethod def _producer_node_for_resource( diff --git a/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py b/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py deleted file mode 100644 index 12c77643..00000000 --- a/ergon_core/ergon_core/core/runtime/services/cohort_schemas.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Pydantic DTOs for cohort-facing backend services and APIs.""" - -from datetime import datetime -from uuid import UUID - -from ergon_core.core.json_types import JsonObject -from ergon_core.core.persistence.telemetry.models import ExperimentCohortStatus -from pydantic import BaseModel, Field - - -class CohortStatusCountsDto(BaseModel): - """Aggregate run counts by lifecycle status.""" - - pending: int = 0 - executing: int = 0 - evaluating: int = 0 - completed: int = 0 - failed: int = 0 - - -class CohortSummaryDto(BaseModel): - """Summary row for cohort list and live updates.""" - - cohort_id: UUID - name: str - description: str | None = None - created_by: str | None = None - created_at: datetime - status: str - total_runs: int = 0 - status_counts: CohortStatusCountsDto = Field(default_factory=CohortStatusCountsDto) - average_score: float | None = None - best_score: float | None = None - worst_score: float | None = None - average_duration_ms: int | None = None - failure_rate: float = 0.0 - stats_updated_at: datetime | None = None - - -class CohortExperimentRowDto(BaseModel): - """One experiment inside a cohort detail view.""" - - experiment_id: UUID - name: str - benchmark_type: str - sample_count: int - total_runs: int = 0 - status_counts: CohortStatusCountsDto = Field(default_factory=CohortStatusCountsDto) - status: str - created_at: datetime - default_model_target: str | None = None - default_evaluator_slug: str | None = None - final_score: float | None = None - total_cost_usd: float | None = None - error_message: str | None = None - - -class CohortDetailDto(BaseModel): - """Full payload for a single cohort detail page.""" - - summary: CohortSummaryDto - experiments: list[CohortExperimentRowDto] = Field(default_factory=list) - - -class UpdateCohortRequest(BaseModel): - """Mutable cohort fields exposed through the operator API.""" - - status: ExperimentCohortStatus - - -class ResolveCohortRequest(BaseModel): - """Request to resolve or create a cohort by name.""" - - name: str - description: str | None = None - created_by: str | None = None - metadata: JsonObject = Field(default_factory=dict) diff --git a/ergon_core/ergon_core/core/runtime/services/cohort_service.py b/ergon_core/ergon_core/core/runtime/services/cohort_service.py deleted file mode 100644 index 918a8c67..00000000 --- a/ergon_core/ergon_core/core/runtime/services/cohort_service.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Application service for experiment cohort queries and resolution.""" - -from dataclasses import dataclass, field -from uuid import UUID - -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.telemetry.evaluation_summary import EvaluationSummary -from ergon_core.core.persistence.telemetry.models import ( - ExperimentCohort, - ExperimentCohortStats, - ExperimentCohortStatus, - ExperimentRecord, - RunRecord, -) -from ergon_core.core.runtime.services.cohort_schemas import ( - CohortDetailDto, - CohortExperimentRowDto, - CohortStatusCountsDto, - CohortSummaryDto, - UpdateCohortRequest, -) -from ergon_core.core.utils import utcnow -from sqlmodel import select - - -@dataclass(frozen=True) -class RubricStatusSummary: - status: str - total_criteria: int - passed: int = 0 - failed: int = 0 - errored: int = 0 - skipped: int = 0 - criterion_statuses: list[str] = field(default_factory=list) - evaluator_names: list[str] = field(default_factory=list) - - -class ExperimentCohortService: - """Resolve cohorts and assemble frontend-facing cohort DTOs.""" - - def resolve_or_create( - self, - name: str, - description: str | None = None, - created_by: str | None = None, - ) -> ExperimentCohort: - """Resolve an existing cohort by name or create a new one.""" - with get_session() as session: - stmt = select(ExperimentCohort).where(ExperimentCohort.name == name) - existing = session.exec(stmt).first() - if existing is not None: - return existing - - cohort = ExperimentCohort( - name=name, - description=description, - created_by=created_by, - ) - session.add(cohort) - session.commit() - session.refresh(cohort) - return cohort - - def list_summaries(self, *, include_archived: bool = False) -> list[CohortSummaryDto]: - """List all cohorts as summary DTOs.""" - with get_session() as session: - stmt = select(ExperimentCohort) - if not include_archived: - stmt = stmt.where(ExperimentCohort.status != ExperimentCohortStatus.ARCHIVED) - cohorts = list(session.exec(stmt).all()) - - results: list[CohortSummaryDto] = [] - for cohort in cohorts: - stats = session.exec( - select(ExperimentCohortStats).where( - ExperimentCohortStats.cohort_id == cohort.id - ) - ).first() - results.append(self._build_summary(cohort, stats)) - return results - - def get_detail(self, cohort_id: UUID) -> CohortDetailDto | None: - """Get a cohort detail DTO with all experiments in the project folder.""" - with get_session() as session: - cohort = session.get(ExperimentCohort, cohort_id) - if cohort is None: - return None - - stats = session.exec( - select(ExperimentCohortStats).where(ExperimentCohortStats.cohort_id == cohort_id) - ).first() - summary = self._build_summary(cohort, stats) - - experiments = list( - session.exec( - select(ExperimentRecord).where(ExperimentRecord.cohort_id == cohort_id) - ).all() - ) - experiment_rows = [ - self._build_experiment_row( - experiment, - list( - session.exec( - select(RunRecord).where(RunRecord.experiment_id == experiment.id) - ).all() - ), - ) - for experiment in experiments - ] - return CohortDetailDto(summary=summary, experiments=experiment_rows) - - def get_summary(self, cohort_id: UUID) -> CohortSummaryDto | None: - """Get a single cohort summary DTO.""" - with get_session() as session: - cohort = session.get(ExperimentCohort, cohort_id) - if cohort is None: - return None - stats = session.exec( - select(ExperimentCohortStats).where(ExperimentCohortStats.cohort_id == cohort_id) - ).first() - return self._build_summary(cohort, stats) - - def update_cohort( - self, cohort_id: UUID, request: UpdateCohortRequest - ) -> CohortSummaryDto | None: - """Update mutable operator-facing cohort properties.""" - with get_session() as session: - cohort = session.get(ExperimentCohort, cohort_id) - if cohort is None: - return None - - cohort.status = request.status.value - cohort.updated_at = utcnow() - session.add(cohort) - session.commit() - session.refresh(cohort) - - stats = session.exec( - select(ExperimentCohortStats).where(ExperimentCohortStats.cohort_id == cohort_id) - ).first() - return self._build_summary(cohort, stats) - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - @staticmethod - def _build_summary( - cohort: ExperimentCohort, - stats: ExperimentCohortStats | None, - ) -> CohortSummaryDto: - return CohortSummaryDto( - cohort_id=cohort.id, - name=cohort.name, - description=cohort.description, - created_by=cohort.created_by, - created_at=cohort.created_at, - status=cohort.status, - total_runs=stats.total_runs if stats else 0, - status_counts=CohortStatusCountsDto( - completed=stats.completed_runs if stats else 0, - failed=stats.failed_runs if stats else 0, - ), - average_score=stats.average_score if stats else None, - best_score=stats.best_score if stats else None, - worst_score=stats.worst_score if stats else None, - average_duration_ms=stats.average_duration_ms if stats else None, - failure_rate=stats.failure_rate if stats else 0.0, - stats_updated_at=stats.updated_at if stats else None, - ) - - @staticmethod - def _build_experiment_row( - experiment: ExperimentRecord, - runs: list[RunRecord], - ) -> CohortExperimentRowDto: - score: float | None = None - total_cost_usd: float | None = None - for run in runs: - summary = run.parsed_summary() - raw_score = summary.get("normalized_score") - if raw_score is None: - raw_score = summary.get("final_score") - if isinstance(raw_score, int | float): - score = float(raw_score) - raw_cost = summary.get("total_cost_usd") - if isinstance(raw_cost, int | float): - total_cost_usd = (total_cost_usd or 0.0) + float(raw_cost) - - status_counts = CohortStatusCountsDto() - for run in runs: - _increment_status_count(status_counts, str(run.status)) - - return CohortExperimentRowDto( - experiment_id=experiment.id, - name=experiment.name, - benchmark_type=experiment.benchmark_type, - sample_count=experiment.sample_count, - total_runs=len(runs), - status_counts=status_counts, - status=_experiment_row_status(experiment.status, status_counts, len(runs)), - created_at=experiment.created_at, - default_model_target=experiment.default_model_target, - default_evaluator_slug=experiment.default_evaluator_slug, - final_score=score, - total_cost_usd=total_cost_usd, - error_message=None, - ) - - -def _increment_status_count(counts: CohortStatusCountsDto, status: str) -> None: - match status: - case "pending": - counts.pending += 1 - case "executing": - counts.executing += 1 - case "evaluating": - counts.evaluating += 1 - case "completed": - counts.completed += 1 - case "failed": - counts.failed += 1 - - -def _rubric_status_summary(summaries: list[EvaluationSummary]) -> RubricStatusSummary: - statuses: list[str] = [] - evaluator_names: list[str] = [] - for summary in summaries: - evaluator_names.append(summary.evaluator_name) - statuses.extend(result.status for result in summary.criterion_results) - - passed = statuses.count("passed") - failed = statuses.count("failed") - errored = statuses.count("errored") - skipped = statuses.count("skipped") - status = "none" - if errored: - status = "errored" - elif failed: - status = "failing" - elif passed: - status = "passing" - - return RubricStatusSummary( - status=status, - total_criteria=len(statuses), - passed=passed, - failed=failed, - errored=errored, - skipped=skipped, - criterion_statuses=statuses, - evaluator_names=evaluator_names, - ) - - -def _experiment_row_status( - experiment_status: str, - counts: CohortStatusCountsDto, - total_runs: int, -) -> str: - if total_runs == 0: - return experiment_status - active_runs = counts.pending + counts.executing + counts.evaluating - if active_runs > 0: - return experiment_status - if counts.failed == total_runs: - return "failed" - if counts.completed == total_runs: - return "completed" - if counts.failed > 0 and counts.completed > 0: - return "completed_with_failures" - return experiment_status - - -experiment_cohort_service = ExperimentCohortService() diff --git a/ergon_core/ergon_core/core/runtime/services/cohort_stats_service.py b/ergon_core/ergon_core/core/runtime/services/cohort_stats_service.py deleted file mode 100644 index 4f1598a8..00000000 --- a/ergon_core/ergon_core/core/runtime/services/cohort_stats_service.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Aggregate stats recomputation for experiment cohorts.""" - -from collections import Counter -from uuid import UUID - -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.shared.enums import RunStatus -from ergon_core.core.persistence.telemetry.models import ( - ExperimentCohortStats, - ExperimentRecord, - RunRecord, -) -from ergon_core.core.utils import utcnow -from sqlmodel import select - - -class ExperimentCohortStatsService: - """Recompute denormalized cohort stats from cohort-scoped runs.""" - - def recompute(self, cohort_id: UUID) -> None: - """Recompute and persist aggregate stats for one cohort.""" - with get_session() as session: - runs = list( - session.exec( - select(RunRecord) - .join(ExperimentRecord) - .where(ExperimentRecord.cohort_id == cohort_id) - ).all() - ) - status_counts = Counter(run.status for run in runs) - - scored_values: list[float] = [ - s for s in (self._score_value(run) for run in runs) if s is not None - ] - - durations_ms = [ - int((run.completed_at - run.started_at).total_seconds() * 1000) - for run in runs - if run.started_at is not None and run.completed_at is not None - ] - - total_runs = len(runs) - failed_runs = status_counts.get(RunStatus.FAILED, 0) - - existing = session.exec( - select(ExperimentCohortStats).where(ExperimentCohortStats.cohort_id == cohort_id) - ).first() - - now = utcnow() - if existing is not None: - existing.total_runs = total_runs - existing.completed_runs = status_counts.get(RunStatus.COMPLETED, 0) - existing.failed_runs = failed_runs - existing.average_score = ( - (sum(scored_values) / len(scored_values)) if scored_values else None - ) - existing.best_score = max(scored_values) if scored_values else None - existing.worst_score = min(scored_values) if scored_values else None - existing.average_duration_ms = ( - (sum(durations_ms) // len(durations_ms)) if durations_ms else None - ) - existing.failure_rate = (failed_runs / total_runs) if total_runs else 0.0 - existing.updated_at = now - session.add(existing) - else: - stats = ExperimentCohortStats( - cohort_id=cohort_id, - total_runs=total_runs, - completed_runs=status_counts.get(RunStatus.COMPLETED, 0), - failed_runs=failed_runs, - average_score=( - (sum(scored_values) / len(scored_values)) if scored_values else None - ), - best_score=max(scored_values) if scored_values else None, - worst_score=min(scored_values) if scored_values else None, - average_duration_ms=( - (sum(durations_ms) // len(durations_ms)) if durations_ms else None - ), - failure_rate=(failed_runs / total_runs) if total_runs else 0.0, - updated_at=now, - ) - session.add(stats) - - session.commit() - - @staticmethod - def _score_value(run: RunRecord) -> float | None: - """Choose the score field used for cohort aggregates.""" - summary = run.parsed_summary() - if not summary: - return None - norm = summary.get("normalized_score") - if norm is not None: - return float(norm) - final = summary.get("final_score") - if final is not None: - return float(final) - return None - - -experiment_cohort_stats_service = ExperimentCohortStatsService() diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_finalization_service.py b/ergon_core/ergon_core/core/runtime/services/workflow_finalization_service.py deleted file mode 100644 index 7e8f5651..00000000 --- a/ergon_core/ergon_core/core/runtime/services/workflow_finalization_service.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Workflow finalization: aggregate evaluations and close the run.""" - -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.shared.enums import RunStatus -from ergon_core.core.persistence.telemetry.models import RunRecord, RunTaskEvaluation -from ergon_core.core.runtime.services.orchestration_dto import ( - FinalizedWorkflowResult, - FinalizeWorkflowCommand, - RunCompletionData, -) -from ergon_core.core.utils import require_not_none, utcnow -from sqlmodel import select - - -class WorkflowFinalizationService: - def finalize(self, command: FinalizeWorkflowCommand) -> FinalizedWorkflowResult: - with get_session() as session: - evals_stmt = select(RunTaskEvaluation).where( - RunTaskEvaluation.run_id == command.run_id, - ) - evaluations = list(session.exec(evals_stmt).all()) - - scores = [e.score for e in evaluations if e.score is not None] - if scores: - final_score: float | None = sum(scores) - normalized_score: float | None = final_score / len(scores) - else: - final_score = None - normalized_score = None - - completion = RunCompletionData( - completed_at=utcnow(), - final_score=final_score, - normalized_score=normalized_score, - ) - - run_record = require_not_none( - session.get(RunRecord, command.run_id), - f"RunRecord {command.run_id} not found", - ) - run_record.status = RunStatus.COMPLETED - run_record.completed_at = completion.completed_at - run_record.summary_json = { - "final_score": completion.final_score, - "normalized_score": completion.normalized_score, - "evaluators_count": len(evaluations), - "total_cost_usd": completion.total_cost_usd, - } - session.add(run_record) - session.commit() - - return FinalizedWorkflowResult( - run_id=command.run_id, - final_score=final_score, - normalized_score=normalized_score, - evaluators_count=len(evaluations), - ) diff --git a/ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py b/ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py deleted file mode 100644 index 53b6ebd4..00000000 --- a/ergon_core/ergon_core/core/runtime/services/workflow_initialization_service.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Workflow initialization: load definitions, seed graph state, find initial tasks.""" - -from ergon_builtins.registry import BENCHMARKS -from ergon_core.core.persistence.definitions.models import ( - ExperimentDefinition, - ExperimentDefinitionTask, -) -from ergon_core.core.persistence.graph import status_conventions as graph_status -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.shared.enums import RunStatus -from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.execution.propagation import get_initial_ready_tasks -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_lookup import GraphNodeLookup -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.orchestration_dto import ( - InitializedWorkflow, - InitializeWorkflowCommand, - TaskDescriptor, -) -from ergon_core.core.utils import require_not_none, utcnow -from sqlmodel import select - - -class WorkflowInitializationService: - async def initialize(self, command: InitializeWorkflowCommand) -> InitializedWorkflow: - with get_session() as session: - definition = require_not_none( - session.get(ExperimentDefinition, command.definition_id), - f"Definition {command.definition_id} not found", - ) - benchmark_cls = require_not_none( - BENCHMARKS.get(definition.benchmark_type), - f"Benchmark {definition.benchmark_type!r} not found", - ) - - tasks_stmt = select(ExperimentDefinitionTask).where( - ExperimentDefinitionTask.experiment_definition_id == command.definition_id, - ) - all_tasks = list(session.exec(tasks_stmt).all()) - - graph_repo = WorkflowGraphRepository() - graph_repo.initialize_from_definition( - session, - command.run_id, - command.definition_id, - initial_node_status=graph_status.PENDING, - initial_edge_status=graph_status.EDGE_PENDING, - task_payload_model=benchmark_cls.task_payload_model, - meta=MutationMeta(actor="system:workflow_init"), - ) - session.commit() - - graph_lookup = GraphNodeLookup(session, command.run_id) - task_descriptors = [ - TaskDescriptor( - task_id=t.id, - task_slug=t.task_slug, - parent_task_id=t.parent_task_id, - node_id=graph_lookup.node_id(t.id), - ) - for t in all_tasks - ] - - run_record = require_not_none( - session.get(RunRecord, command.run_id), - f"RunRecord {command.run_id} not found", - ) - run_record.status = RunStatus.EXECUTING - run_record.started_at = utcnow() - session.add(run_record) - session.commit() - - ready_ids = await get_initial_ready_tasks( - session, - command.run_id, - command.definition_id, - graph_repo=graph_repo, - graph_lookup=graph_lookup, - ) - - ready_descriptors = [td for td in task_descriptors if td.task_id in set(ready_ids)] - - root_count = sum(1 for t in all_tasks if t.parent_task_id is None) - - return InitializedWorkflow( - run_id=command.run_id, - definition_id=command.definition_id, - benchmark_type=definition.benchmark_type, - total_tasks=len(all_tasks), - total_root_tasks=root_count, - pending_tasks=task_descriptors, - initial_ready_tasks=ready_descriptors, - ) From 0499ecc296f4c2410bffbce8f91daf9f44fa89ca Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:45 +0100 Subject: [PATCH 54/66] refactor: move runtime jobs into application package Made-with: Cursor --- .../inngest => application/jobs}/__init__.py | 0 .../jobs}/cancel_orphan_subtasks.py | 37 +--- .../jobs}/check_evaluators.py | 43 ++-- .../jobs}/cleanup_cancelled_task.py | 22 +- .../jobs}/complete_workflow.py | 35 ++-- .../jobs}/evaluate_task_run.py | 68 +++--- .../jobs}/execute_task.py | 101 +++++---- .../jobs}/fail_workflow.py | 27 +-- .../core/application/jobs/models.py | 198 ++++++++++++++++++ .../jobs}/persist_outputs.py | 26 +-- .../jobs}/propagate_execution.py | 59 ++---- .../jobs}/run_cleanup.py | 20 +- .../jobs}/sandbox_setup.py | 42 ++-- .../jobs}/start_workflow.py | 33 +-- .../jobs}/worker_execute.py | 105 ++++++---- .../ergon_core/core/runtime/inngest/client.py | 32 --- .../core/runtime/inngest/registry.py | 47 ----- 17 files changed, 467 insertions(+), 428 deletions(-) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/__init__.py (100%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/cancel_orphan_subtasks.py (72%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/check_evaluators.py (70%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/cleanup_cancelled_task.py (65%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/complete_workflow.py (75%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/evaluate_task_run.py (69%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/execute_task.py (87%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/fail_workflow.py (78%) create mode 100644 ergon_core/ergon_core/core/application/jobs/models.py rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/persist_outputs.py (75%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/propagate_execution.py (76%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/run_cleanup.py (78%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/sandbox_setup.py (71%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/start_workflow.py (87%) rename ergon_core/ergon_core/core/{runtime/inngest => application/jobs}/worker_execute.py (60%) delete mode 100644 ergon_core/ergon_core/core/runtime/inngest/client.py delete mode 100644 ergon_core/ergon_core/core/runtime/inngest/registry.py diff --git a/ergon_core/ergon_core/core/runtime/inngest/__init__.py b/ergon_core/ergon_core/core/application/jobs/__init__.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/inngest/__init__.py rename to ergon_core/ergon_core/core/application/jobs/__init__.py diff --git a/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py b/ergon_core/ergon_core/core/application/jobs/cancel_orphan_subtasks.py similarity index 72% rename from ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py rename to ergon_core/ergon_core/core/application/jobs/cancel_orphan_subtasks.py index a79efb7d..c46005a9 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py +++ b/ergon_core/ergon_core/core/application/jobs/cancel_orphan_subtasks.py @@ -12,26 +12,23 @@ """ import logging +from typing import Any from uuid import UUID -import inngest +from ergon_core.core.application.tasks.management import TaskManagementService +from ergon_core.core.infrastructure.inngest.client import InngestEvent, inngest_client from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.runtime.events.task_events import ( +from ergon_core.core.application.events.task_events import ( CancelCause, TaskCancelledEvent, TaskFailedEvent, ) -from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client -from ergon_core.core.runtime.services.subtask_blocking_service import SubtaskBlockingService -from ergon_core.core.runtime.services.subtask_cancellation_service import ( - SubtaskCancellationService, -) logger = logging.getLogger(__name__) async def _cancel_orphans_for( - ctx: inngest.Context, + ctx: Any, *, run_id: UUID, definition_id: UUID, @@ -39,7 +36,7 @@ async def _cancel_orphans_for( cause: CancelCause, ) -> int: """Two durable steps: scan-and-cancel, then emit events.""" - svc = SubtaskCancellationService() + svc = TaskManagementService() async def _scan_and_cancel() -> dict: with get_session() as session: @@ -62,7 +59,7 @@ async def _scan_and_cancel() -> dict: async def _emit_events() -> None: await inngest_client.send( - [inngest.Event(name="task/cancelled", data=e) for e in scan_result["events"]] + [InngestEvent(name="task/cancelled", data=e) for e in scan_result["events"]] ) await ctx.step.run("emit-cancelled-events", _emit_events) @@ -70,21 +67,14 @@ async def _emit_events() -> None: return len(scan_result["cancelled_node_ids"]) -@inngest_client.create_function( - fn_id="block-descendants-on-failed", - trigger=inngest.TriggerEvent(event="task/failed"), - cancel=RUN_CANCEL, - retries=1, -) -async def block_descendants_on_failed_fn(ctx: inngest.Context) -> int: +async def run_block_descendants_on_failed_job(ctx: Any, payload: TaskFailedEvent) -> int: """When a parent fails, PENDING/READY containment descendants become BLOCKED. RUNNING descendants are not interrupted. Horizontal (edge-based) successor BLOCKED propagation is handled separately in propagation.py. """ - payload = TaskFailedEvent.model_validate(ctx.event.data) logger.info("block-descendants-on-failed parent=%s", payload.node_id) - svc = SubtaskBlockingService() + svc = TaskManagementService() async def _block_descendants() -> list[str]: with get_session() as session: @@ -101,14 +91,7 @@ async def _block_descendants() -> list[str]: return len(blocked) -@inngest_client.create_function( - fn_id="cancel-orphans-on-cancelled", - trigger=inngest.TriggerEvent(event="task/cancelled"), - cancel=RUN_CANCEL, - retries=1, -) -async def cancel_orphans_on_cancelled_fn(ctx: inngest.Context) -> int: - payload = TaskCancelledEvent.model_validate(ctx.event.data) +async def run_cancel_orphans_on_cancelled_job(ctx: Any, payload: TaskCancelledEvent) -> int: logger.info("cancel-orphans parent=%s cause=parent_terminal", payload.node_id) return await _cancel_orphans_for( ctx, diff --git a/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py b/ergon_core/ergon_core/core/application/jobs/check_evaluators.py similarity index 70% rename from ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py rename to ergon_core/ergon_core/core/application/jobs/check_evaluators.py index 31d00364..6099e757 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/check_evaluators.py +++ b/ergon_core/ergon_core/core/application/jobs/check_evaluators.py @@ -7,39 +7,30 @@ """ import logging +from typing import Any -import inngest -from ergon_core.core.sandbox.lifecycle import terminate_sandbox_by_id -from ergon_core.core.runtime.events.task_events import ( - TaskCompletedEvent, -) -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.inngest.evaluate_task_run import evaluate_task_run -from ergon_core.core.runtime.services.child_function_payloads import ( - EvaluateTaskRunRequest, -) -from ergon_core.core.runtime.services.evaluation_dto import ( +from ergon_core.core.application.evaluation.models import ( DispatchEvaluatorsCommand, ) -from ergon_core.core.runtime.services.evaluator_dispatch_service import ( - EvaluatorDispatchService, +from ergon_core.core.application.evaluation.service import ( + EvaluationService, ) -from ergon_core.core.runtime.services.inngest_function_results import ( - EvaluateTaskRunResult, - EvaluatorsResult, +from ergon_core.core.application.jobs.models import EvaluateTaskRunRequest +from ergon_core.core.application.jobs.models import EvaluateTaskRunResult, EvaluatorsResult +from ergon_core.core.application.events.task_events import ( + TaskCompletedEvent, ) +from ergon_core.core.infrastructure.sandbox.lifecycle import terminate_sandbox_by_id logger = logging.getLogger(__name__) -@inngest_client.create_function( - fn_id="task-check-evaluators", - trigger=inngest.TriggerEvent(event=TaskCompletedEvent.name), - retries=1, - output_type=EvaluatorsResult, -) -async def check_and_run_evaluators(ctx: inngest.Context) -> EvaluatorsResult: - payload = TaskCompletedEvent.model_validate(ctx.event.data) +async def run_check_evaluators_job( + ctx: Any, + payload: TaskCompletedEvent, + *, + evaluate_task_run_function: Any, +) -> EvaluatorsResult: if payload.node_id is None: await _terminate_sandbox(payload.sandbox_id) return EvaluatorsResult( @@ -48,7 +39,7 @@ async def check_and_run_evaluators(ctx: inngest.Context) -> EvaluatorsResult: evaluators_run=0, ) - dispatch_service = EvaluatorDispatchService() + dispatch_service = EvaluationService() dispatch = dispatch_service.prepare_dispatch( DispatchEvaluatorsCommand( run_id=payload.run_id, @@ -71,7 +62,7 @@ async def check_and_run_evaluators(ctx: inngest.Context) -> EvaluatorsResult: for evaluator_payload in dispatch.valid_evaluators: result: EvaluateTaskRunResult = await ctx.step.invoke( f"evaluate-{evaluator_payload.evaluator_binding_key}", - function=evaluate_task_run, + function=evaluate_task_run_function, data=EvaluateTaskRunRequest( run_id=payload.run_id, definition_id=payload.definition_id, diff --git a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py b/ergon_core/ergon_core/core/application/jobs/cleanup_cancelled_task.py similarity index 65% rename from ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py rename to ergon_core/ergon_core/core/application/jobs/cleanup_cancelled_task.py index 27f7c8ae..85b9b8a1 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py +++ b/ergon_core/ergon_core/core/application/jobs/cleanup_cancelled_task.py @@ -8,27 +8,19 @@ import logging -import inngest -from ergon_core.core.dashboard.provider import get_dashboard_emitter -from ergon_core.core.json_types import JsonObject +from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter +from ergon_core.core.shared.json_types import JsonObject from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.runtime.events.task_events import TaskCancelledEvent -from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client -from ergon_core.core.runtime.services.task_cleanup_dto import CleanupResult -from ergon_core.core.runtime.services.task_cleanup_service import TaskCleanupService +from ergon_core.core.application.events.task_events import TaskCancelledEvent +from ergon_core.core.application.tasks.models import CleanupResult +from ergon_core.core.application.tasks.cleanup import TaskCleanupService +from typing import Any logger = logging.getLogger(__name__) -@inngest_client.create_function( - fn_id="cleanup-cancelled-task", - trigger=inngest.TriggerEvent(event="task/cancelled"), - cancel=RUN_CANCEL, - retries=3, -) -async def cleanup_cancelled_task_fn(ctx: inngest.Context) -> JsonObject: +async def run_cleanup_cancelled_task_job(ctx: Any, payload: TaskCancelledEvent) -> JsonObject: """Clean up a single cancelled task's resources.""" - payload = TaskCancelledEvent.model_validate(ctx.event.data) logger.info( "cleanup-cancelled node_id=%s execution_id=%s cause=%s", payload.node_id, diff --git a/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py b/ergon_core/ergon_core/core/application/jobs/complete_workflow.py similarity index 75% rename from ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py rename to ergon_core/ergon_core/core/application/jobs/complete_workflow.py index 531d3085..975aab2e 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py +++ b/ergon_core/ergon_core/core/application/jobs/complete_workflow.py @@ -3,20 +3,17 @@ import logging from datetime import UTC, datetime -import inngest -from ergon_core.core.dashboard import emit_cohort_updated_for_run -from ergon_core.core.dashboard.provider import get_dashboard_emitter +from ergon_core.core.infrastructure.dashboard import emit_cohort_updated_for_run +from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import ExperimentRecord, RunRecord -from ergon_core.core.runtime.events.infrastructure_events import RunCleanupEvent -from ergon_core.core.runtime.events.task_events import WorkflowCompletedEvent -from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client -from ergon_core.core.runtime.services.inngest_function_results import WorkflowCompleteResult -from ergon_core.core.runtime.services.orchestration_dto import FinalizeWorkflowCommand -from ergon_core.core.runtime.services.workflow_finalization_service import ( - WorkflowFinalizationService, -) -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.application.events.infrastructure_events import RunCleanupEvent +from ergon_core.core.application.events.task_events import WorkflowCompletedEvent +from ergon_core.core.infrastructure.inngest.client import InngestEvent, inngest_client +from ergon_core.core.application.jobs.models import WorkflowCompleteResult +from ergon_core.core.application.workflows.orchestration import FinalizeWorkflowCommand +from ergon_core.core.application.workflows.service import WorkflowService +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, get_trace_sink, workflow_complete_context, @@ -26,19 +23,11 @@ logger = logging.getLogger(__name__) -@inngest_client.create_function( - fn_id="workflow-complete", - trigger=inngest.TriggerEvent(event="workflow/completed"), - cancel=RUN_CANCEL, - retries=1, - output_type=WorkflowCompleteResult, -) -async def complete_workflow_fn(ctx: inngest.Context) -> WorkflowCompleteResult: - payload = WorkflowCompletedEvent.model_validate(ctx.event.data) +async def run_complete_workflow_job(payload: WorkflowCompletedEvent) -> WorkflowCompleteResult: logger.info("workflow-complete run_id=%s", payload.run_id) span_start = datetime.now(UTC) - svc = WorkflowFinalizationService() + svc = WorkflowService() finalized = svc.finalize( FinalizeWorkflowCommand( run_id=payload.run_id, @@ -63,7 +52,7 @@ async def complete_workflow_fn(ctx: inngest.Context) -> WorkflowCompleteResult: ) await inngest_client.send( - inngest.Event( + InngestEvent( name=RunCleanupEvent.name, data=RunCleanupEvent( run_id=payload.run_id, diff --git a/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py b/ergon_core/ergon_core/core/application/jobs/evaluate_task_run.py similarity index 69% rename from ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py rename to ergon_core/ergon_core/core/application/jobs/evaluate_task_run.py index 43cf0869..53eb1d49 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/evaluate_task_run.py +++ b/ergon_core/ergon_core/core/application/jobs/evaluate_task_run.py @@ -7,48 +7,33 @@ import logging from datetime import UTC, datetime -import inngest -from ergon_builtins.registry import BENCHMARKS, EVALUATORS, SANDBOX_MANAGERS -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.core.dashboard.provider import get_dashboard_emitter -from ergon_core.core.persistence.queries import queries -from ergon_core.core.sandbox.manager import DefaultSandboxManager -from ergon_core.core.runtime.errors import ContractViolationError, RegistryLookupError -from ergon_core.core.runtime.evaluation.evaluation_schemas import TaskEvaluationContext -from ergon_core.core.runtime.evaluation.inngest_executor import InngestCriterionExecutor -from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client -from ergon_core.core.runtime.services.child_function_payloads import ( - EvaluateTaskRunRequest, +from ergon_core.api.benchmark import EmptyTaskPayload, Task +from ergon_core.api.registry import registry +from ergon_core.core.application.experiments.repository import DefinitionRepository +from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.infrastructure.sandbox.manager import DefaultSandboxManager +from ergon_core.core.infrastructure.inngest.errors import ContractViolationError, RegistryLookupError +from ergon_core.core.application.evaluation.models import TaskEvaluationContext +from ergon_core.core.application.evaluation.inngest_executor import InngestCriterionExecutor +from ergon_core.core.application.jobs.models import EvaluateTaskRunRequest +from ergon_core.core.application.evaluation.service import ( + EvaluationService, ) -from ergon_core.core.runtime.services.evaluation_persistence_service import ( - EvaluationPersistenceService, -) -from ergon_core.core.runtime.services.inngest_function_results import ( - EvaluateTaskRunResult, -) -from ergon_core.core.runtime.services.rubric_evaluation_service import ( - RubricEvaluationService, -) -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.application.jobs.models import EvaluateTaskRunResult +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, evaluation_task_context, get_trace_sink, ) from pydantic import BaseModel +from typing import Any logger = logging.getLogger(__name__) -evaluation_persistence = EvaluationPersistenceService() +evaluation_persistence = EvaluationService() -@inngest_client.create_function( - fn_id="evaluate-task-run", - trigger=inngest.TriggerEvent(event="task/evaluate"), - cancel=RUN_CANCEL, - retries=1, - output_type=EvaluateTaskRunResult, -) -async def evaluate_task_run(ctx: inngest.Context) -> EvaluateTaskRunResult: - payload = EvaluateTaskRunRequest.model_validate(ctx.event.data) +async def run_evaluate_task_run_job(ctx: Any, payload: EvaluateTaskRunRequest) -> EvaluateTaskRunResult: run_id = payload.run_id definition_task_id = payload.task_id node_id = payload.node_id @@ -59,7 +44,7 @@ async def evaluate_task_run(ctx: inngest.Context) -> EvaluateTaskRunResult: agent_reasoning = payload.agent_reasoning span_start = datetime.now(UTC) - evaluator_cls = EVALUATORS.get(evaluator_type) + evaluator_cls = registry.evaluators.get(evaluator_type) if evaluator_cls is None: raise RegistryLookupError( "evaluator", @@ -75,10 +60,12 @@ async def evaluate_task_run(ctx: inngest.Context) -> EvaluateTaskRunResult: # ``DefaultSandboxManager`` for benchmarks that don't register a custom # one. The manager is a singleton per class, so this doesn't spin up a # new instance per evaluation. - definition = queries.definitions.get(payload.definition_id) - benchmark_type = definition.benchmark_type if definition is not None else None + definition_repo = DefinitionRepository() + with get_session() as session: + definition = definition_repo.get(session, payload.definition_id) + benchmark_type = definition.benchmark_type if definition is not None else None manager_cls = ( - SANDBOX_MANAGERS.get(benchmark_type, DefaultSandboxManager) + registry.sandbox_managers.get(benchmark_type, DefaultSandboxManager) if benchmark_type is not None else DefaultSandboxManager ) @@ -99,7 +86,8 @@ async def evaluate_task_run(ctx: inngest.Context) -> EvaluateTaskRunResult: task_id=node_id, ) - task_row, instance_row = queries.definitions.get_task_with_instance(definition_task_id) + with get_session() as session: + task_row, instance_row = definition_repo.task_with_instance(session, definition_task_id) task_input = task_row.description task_context = TaskEvaluationContext( @@ -109,20 +97,20 @@ async def evaluate_task_run(ctx: inngest.Context) -> EvaluateTaskRunResult: sandbox_id=payload.sandbox_id, ) - benchmark_cls = BENCHMARKS.get(benchmark_type) if benchmark_type is not None else None + benchmark_cls = registry.benchmarks.get(benchmark_type) if benchmark_type is not None else None task_payload = ( task_row.task_payload_as(benchmark_cls.task_payload_model) if benchmark_cls is not None else None ) - task = BenchmarkTask[BaseModel]( + task = Task[BaseModel]( task_slug=task_row.task_slug, instance_key=instance_row.instance_key, description=task_input, task_payload=task_payload or EmptyTaskPayload(), ) - service = RubricEvaluationService(criterion_executor=executor) + service = EvaluationService(criterion_executor=executor) try: service_result = await service.evaluate( task_context=task_context, diff --git a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py b/ergon_core/ergon_core/core/application/jobs/execute_task.py similarity index 87% rename from ergon_core/ergon_core/core/runtime/inngest/execute_task.py rename to ergon_core/ergon_core/core/application/jobs/execute_task.py index cd63cd3d..35e5a3fd 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/execute_task.py +++ b/ergon_core/ergon_core/core/application/jobs/execute_task.py @@ -7,37 +7,34 @@ import logging import traceback from datetime import UTC, datetime +from typing import Any -import inngest -from ergon_core.core.runtime.errors import ContractViolationError -from ergon_core.core.runtime.events.task_events import ( - TaskCompletedEvent, - TaskFailedEvent, - TaskReadyEvent, -) -from ergon_core.core.runtime.inngest.persist_outputs import persist_outputs_fn -from ergon_core.core.runtime.inngest.sandbox_setup import sandbox_setup_fn -from ergon_core.core.runtime.inngest.worker_execute import worker_execute_fn -from ergon_core.core.runtime.inngest.client import RUN_CANCEL, TASK_CANCEL, inngest_client -from ergon_core.core.runtime.services.child_function_payloads import ( +from ergon_core.core.application.jobs.models import ( PersistOutputsRequest, - SandboxSetupRequest, - WorkerExecuteRequest, -) -from ergon_core.core.runtime.services.inngest_function_results import ( PersistOutputsResult, SandboxReadyResult, + SandboxSetupRequest, TaskExecuteResult, - WorkerExecuteResult, + WorkerExecuteJobRequest, + WorkerExecuteJobResult, ) -from ergon_core.core.runtime.services.orchestration_dto import ( +from ergon_core.core.application.tasks.execution import TaskExecutionService +from ergon_core.core.application.workflows.orchestration import ( FailTaskExecutionCommand, FinalizeTaskExecutionCommand, PreparedTaskExecution, PrepareTaskExecutionCommand, ) -from ergon_core.core.runtime.services.task_execution_service import TaskExecutionService -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.infrastructure.inngest.client import InngestEvent, inngest_client +from ergon_core.core.infrastructure.inngest.errors import ContractViolationError, NonRetriableError +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.persistence.telemetry.models import RunRecord +from ergon_core.core.application.events.task_events import ( + TaskCompletedEvent, + TaskFailedEvent, + TaskReadyEvent, +) +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, get_trace_sink, task_execute_context, @@ -48,7 +45,7 @@ async def _prepare_execution( - ctx: inngest.Context, + ctx: Any, svc: TaskExecutionService, payload: TaskReadyEvent, ) -> PreparedTaskExecution: @@ -66,35 +63,47 @@ async def _prepare() -> PreparedTaskExecution: async def _setup_sandbox( - ctx: inngest.Context, + ctx: Any, payload: TaskReadyEvent, prepared: PreparedTaskExecution, + sandbox_setup_function: Any, ) -> SandboxReadyResult: # Dynamic subtasks have no static task_id. Use node_id as the sandbox key # so each subtask gets its own isolated sandbox slot in the manager registry. sandbox_task_key = payload.task_id or prepared.node_id return await ctx.step.invoke( "sandbox-setup", - function=sandbox_setup_fn, + function=sandbox_setup_function, data=SandboxSetupRequest( run_id=payload.run_id, definition_id=payload.definition_id, task_id=sandbox_task_key, benchmark_type=prepared.benchmark_type, + sandbox_slug=_sandbox_slug_for_run(payload.run_id), ).model_dump(), ) +def _sandbox_slug_for_run(run_id) -> str | None: + session = get_session() + try: + run = session.get(RunRecord, run_id) + return None if run is None else run.sandbox_slug + finally: + session.close() + + async def _run_worker( - ctx: inngest.Context, + ctx: Any, payload: TaskReadyEvent, prepared: PreparedTaskExecution, sandbox_result: SandboxReadyResult, -) -> WorkerExecuteResult: + worker_execute_function: Any, +) -> WorkerExecuteJobResult: return await ctx.step.invoke( "worker-execute", - function=worker_execute_fn, - data=WorkerExecuteRequest( + function=worker_execute_function, + data=WorkerExecuteJobRequest( run_id=payload.run_id, definition_id=payload.definition_id, task_id=payload.task_id, @@ -112,15 +121,16 @@ async def _run_worker( async def _persist_outputs( - ctx: inngest.Context, + ctx: Any, payload: TaskReadyEvent, prepared: PreparedTaskExecution, sandbox_result: SandboxReadyResult, + persist_outputs_function: Any, ) -> PersistOutputsResult: output_task_key = payload.task_id or prepared.node_id return await ctx.step.invoke( "persist-outputs", - function=persist_outputs_fn, + function=persist_outputs_function, data=PersistOutputsRequest( run_id=payload.run_id, definition_id=payload.definition_id, @@ -129,6 +139,7 @@ async def _persist_outputs( sandbox_id=sandbox_result.sandbox_id, output_dir=sandbox_result.output_dir, benchmark_type=prepared.benchmark_type, + sandbox_slug=_sandbox_slug_for_run(payload.run_id), ).model_dump(), ) @@ -139,7 +150,7 @@ async def _emit_task_completed( sandbox_id: str, ) -> None: await inngest_client.send( - inngest.Event( + InngestEvent( name=TaskCompletedEvent.name, data=TaskCompletedEvent( run_id=payload.run_id, @@ -160,7 +171,7 @@ async def _emit_task_failed( sandbox_id: str | None, ) -> None: await inngest_client.send( - inngest.Event( + InngestEvent( name=TaskFailedEvent.name, data=TaskFailedEvent( run_id=payload.run_id, @@ -178,16 +189,14 @@ async def _emit_task_failed( # retries=0: side effects (sandbox creation, model API calls, DB writes) # would duplicate on retry. Failure propagates via TaskFailedEvent. # Concurrency bounded by E2B sandbox quota and Postgres connection pool. -@inngest_client.create_function( - fn_id="task-execute", - trigger=inngest.TriggerEvent(event="task/ready"), - cancel=[*RUN_CANCEL, *TASK_CANCEL], - retries=0, - concurrency=[inngest.Concurrency(limit=15)], - output_type=TaskExecuteResult, -) -async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: - payload = TaskReadyEvent.model_validate(ctx.event.data) +async def run_execute_task_job( + ctx: Any, + payload: TaskReadyEvent, + *, + sandbox_setup_function: Any, + worker_execute_function: Any, + persist_outputs_function: Any, +) -> TaskExecuteResult: logger.info("task-execute run_id=%s task_id=%s", payload.run_id, payload.task_id) span_start = datetime.now(UTC) @@ -213,7 +222,7 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: task_id=payload.task_id, ) - sandbox_result = await _setup_sandbox(ctx, payload, prepared) + sandbox_result = await _setup_sandbox(ctx, payload, prepared, sandbox_setup_function) if not sandbox_result.sandbox_id: raise ContractViolationError( "sandbox-setup returned empty sandbox_id", @@ -222,10 +231,10 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: ) task_sandbox_id = sandbox_result.sandbox_id - worker_result = await _run_worker(ctx, payload, prepared, sandbox_result) + worker_result = await _run_worker(ctx, payload, prepared, sandbox_result, worker_execute_function) if not worker_result.success: - await _persist_outputs(ctx, payload, prepared, sandbox_result) + await _persist_outputs(ctx, payload, prepared, sandbox_result, persist_outputs_function) error_msg = worker_result.error or "Worker execution failed" await svc.finalize_failure( FailTaskExecutionCommand( @@ -245,7 +254,7 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: error=error_msg, ) - persist_result = await _persist_outputs(ctx, payload, prepared, sandbox_result) + persist_result = await _persist_outputs(ctx, payload, prepared, sandbox_result, persist_outputs_function) await svc.finalize_success( FinalizeTaskExecutionCommand( @@ -368,4 +377,4 @@ async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: payload.node_id, ) - raise inngest.NonRetriableError(message=error_msg) from exc + raise NonRetriableError(message=error_msg) from exc diff --git a/ergon_core/ergon_core/core/runtime/inngest/fail_workflow.py b/ergon_core/ergon_core/core/application/jobs/fail_workflow.py similarity index 78% rename from ergon_core/ergon_core/core/runtime/inngest/fail_workflow.py rename to ergon_core/ergon_core/core/application/jobs/fail_workflow.py index bfd97faf..627d7812 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/fail_workflow.py +++ b/ergon_core/ergon_core/core/application/jobs/fail_workflow.py @@ -3,36 +3,27 @@ import logging from datetime import UTC, datetime -import inngest from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import RunStatus from ergon_core.core.persistence.telemetry.models import ExperimentRecord, RunRecord -from ergon_core.core.runtime.errors import DataIntegrityError -from ergon_core.core.runtime.events.infrastructure_events import RunCleanupEvent -from ergon_core.core.runtime.events.task_events import WorkflowFailedEvent -from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client -from ergon_core.core.runtime.services.inngest_function_results import WorkflowFailedResult -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.infrastructure.inngest.errors import DataIntegrityError +from ergon_core.core.application.events.infrastructure_events import RunCleanupEvent +from ergon_core.core.application.events.task_events import WorkflowFailedEvent +from ergon_core.core.infrastructure.inngest.client import InngestEvent, inngest_client +from ergon_core.core.application.jobs.models import WorkflowFailedResult +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, get_trace_sink, truncate_text, workflow_failed_context, workflow_root_context, ) -from ergon_core.core.utils import utcnow +from ergon_core.core.shared.utils import utcnow logger = logging.getLogger(__name__) -@inngest_client.create_function( - fn_id="workflow-failed", - trigger=inngest.TriggerEvent(event="workflow/failed"), - cancel=RUN_CANCEL, - retries=1, - output_type=WorkflowFailedResult, -) -async def fail_workflow_fn(ctx: inngest.Context) -> WorkflowFailedResult: - payload = WorkflowFailedEvent.model_validate(ctx.event.data) +async def run_fail_workflow_job(payload: WorkflowFailedEvent) -> WorkflowFailedResult: logger.info("workflow-failed run_id=%s error=%s", payload.run_id, payload.error) span_start = datetime.now(UTC) @@ -47,7 +38,7 @@ async def fail_workflow_fn(ctx: inngest.Context) -> WorkflowFailedResult: session.commit() await inngest_client.send( - inngest.Event( + InngestEvent( name=RunCleanupEvent.name, data=RunCleanupEvent( run_id=payload.run_id, diff --git a/ergon_core/ergon_core/core/application/jobs/models.py b/ergon_core/ergon_core/core/application/jobs/models.py new file mode 100644 index 00000000..ef1fa42f --- /dev/null +++ b/ergon_core/ergon_core/core/application/jobs/models.py @@ -0,0 +1,198 @@ +"""Pure application job request and result models.""" + +from typing import ClassVar, Literal +from uuid import UUID + +from ergon_core.core.application.events.base import InngestEventContract +from ergon_core.core.shared.json_types import JsonObject +from pydantic import BaseModel, Field, model_validator + + +class SandboxSetupRequest(InngestEventContract): + model_config = {"extra": "allow"} + name: ClassVar[str] = "task/sandbox-setup" + + run_id: UUID + definition_id: UUID + task_id: UUID + benchmark_type: str + sandbox_slug: str | None = None + input_resource_ids: list[UUID] = Field(default_factory=list) + envs: dict[str, str] = Field(default_factory=dict) + + +class WorkerExecuteRequest(InngestEventContract): + model_config = {"extra": "allow"} + name: ClassVar[str] = "task/worker-execute" + + run_id: UUID + definition_id: UUID + task_id: UUID | None + execution_id: UUID + sandbox_id: str + task_slug: str + task_description: str + assigned_worker_slug: str + worker_type: str + model_target: str + benchmark_type: str + node_id: UUID | None = None + + @model_validator(mode="after") + def _has_static_or_dynamic_identity(self) -> "WorkerExecuteRequest": + if self.task_id is None and self.node_id is None: + raise ValueError("WorkerExecuteRequest requires task_id or node_id") + return self + + +class PersistOutputsRequest(InngestEventContract): + model_config = {"extra": "allow"} + name: ClassVar[str] = "task/persist-outputs" + + run_id: UUID + definition_id: UUID + task_id: UUID + execution_id: UUID + sandbox_id: str | None = None + output_dir: str | None = None + benchmark_type: str + + +class EvaluateTaskRunRequest(InngestEventContract): + model_config = {"extra": "allow"} + name: ClassVar[str] = "task/evaluate" + + run_id: UUID + definition_id: UUID + task_id: UUID | None = None + node_id: UUID + execution_id: UUID + evaluator_id: UUID + evaluator_binding_key: str + evaluator_type: str + agent_reasoning: str | None = None + sandbox_id: str | None = None + + +class WorkflowStartResult(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + initial_ready_tasks: int = 0 + total_tasks: int = 0 + + +class TaskExecuteResult(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + task_id: UUID | None + execution_id: UUID + success: bool = False + skipped: bool = False + skip_reason: str | None = None + outputs_count: int = 0 + error: str | None = None + + +class TaskPropagateResult(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + task_id: UUID | None + newly_ready_tasks: int = 0 + workflow_complete: bool = False + workflow_failed: bool = False + + +class WorkflowCompleteResult(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + status: Literal["completed"] = "completed" + final_score: float | None = None + normalized_score: float | None = None + evaluators_count: int = 0 + + +class WorkflowFailedResult(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + status: Literal["failed"] = "failed" + error: str | None = None + + +class SandboxReadyResult(BaseModel): + model_config = {"frozen": True} + + sandbox_id: str + output_dir: str | None = None + + +class WorkerExecuteResult(BaseModel): + model_config = {"frozen": True} + + success: bool = False + final_assistant_message: str | None = None + error: str | None = None + error_json: JsonObject | None = None + + +class PersistOutputsResult(BaseModel): + model_config = {"frozen": True} + + output_resource_ids: list[UUID] = Field(default_factory=list) + outputs_count: int = 0 + + +class EvaluatorsResult(BaseModel): + model_config = {"frozen": True} + + task_id: UUID | None + evaluators_found: int = 0 + evaluators_run: int = 0 + scores: list[float | None] = Field(default_factory=list) + + +class EvaluateTaskRunResult(BaseModel): + model_config = {"frozen": True} + + score: float | None = None + passed: bool | None = None + evaluator_name: str = "" # slopcop: ignore[no-str-empty-default] + error: str | None = None + + +class RunCleanupResult(BaseModel): + model_config = {"frozen": True} + + run_id: UUID + status: str | None = None + sandbox_terminated: bool = False + sandbox_id: str | None = None + error: str | None = None + + +WorkerExecuteJobRequest = WorkerExecuteRequest +WorkerExecuteJobResult = WorkerExecuteResult + +__all__ = [ + "EvaluateTaskRunRequest", + "EvaluateTaskRunResult", + "EvaluatorsResult", + "PersistOutputsRequest", + "PersistOutputsResult", + "RunCleanupResult", + "SandboxReadyResult", + "SandboxSetupRequest", + "TaskExecuteResult", + "TaskPropagateResult", + "WorkerExecuteRequest", + "WorkerExecuteResult", + "WorkerExecuteJobRequest", + "WorkerExecuteJobResult", + "WorkflowCompleteResult", + "WorkflowFailedResult", + "WorkflowStartResult", +] diff --git a/ergon_core/ergon_core/core/runtime/inngest/persist_outputs.py b/ergon_core/ergon_core/core/application/jobs/persist_outputs.py similarity index 75% rename from ergon_core/ergon_core/core/runtime/inngest/persist_outputs.py rename to ergon_core/ergon_core/core/application/jobs/persist_outputs.py index d4747f3e..80b853ab 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/persist_outputs.py +++ b/ergon_core/ergon_core/core/application/jobs/persist_outputs.py @@ -11,18 +11,15 @@ import logging from datetime import UTC, datetime -import inngest -from ergon_builtins.registry import SANDBOX_MANAGERS -from ergon_core.core.sandbox.manager import ( +from ergon_core.api.registry import registry +from ergon_core.core.infrastructure.sandbox.manager import ( BaseSandboxManager, DefaultSandboxManager, ) -from ergon_core.core.sandbox.resource_publisher import SandboxResourcePublisher -from ergon_core.core.runtime.errors import ContractViolationError -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.child_function_payloads import PersistOutputsRequest -from ergon_core.core.runtime.services.inngest_function_results import PersistOutputsResult -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.infrastructure.sandbox.resource_publisher import SandboxResourcePublisher +from ergon_core.core.infrastructure.inngest.errors import ContractViolationError +from ergon_core.core.application.jobs.models import PersistOutputsRequest, PersistOutputsResult +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, get_trace_sink, persist_outputs_context, @@ -31,15 +28,8 @@ logger = logging.getLogger(__name__) -@inngest_client.create_function( - fn_id="persist-outputs", - trigger=inngest.TriggerEvent(event="task/persist-outputs"), - retries=1, - output_type=PersistOutputsResult, -) -async def persist_outputs_fn(ctx: inngest.Context) -> PersistOutputsResult: +async def run_persist_outputs_job(payload: PersistOutputsRequest) -> PersistOutputsResult: """Sync sandbox publish dirs to the blob store and register resources.""" - payload = PersistOutputsRequest.model_validate(ctx.event.data) run_id = payload.run_id task_id = payload.task_id execution_id = payload.execution_id @@ -60,7 +50,7 @@ async def persist_outputs_fn(ctx: inngest.Context) -> PersistOutputsResult: task_id=task_id, ) - manager_cls = SANDBOX_MANAGERS.get(payload.benchmark_type, DefaultSandboxManager) + manager_cls = registry.sandbox_managers.get(payload.benchmark_type, DefaultSandboxManager) sandbox_manager = manager_cls() outputs_count = await _publish_resources(sandbox_manager, payload) diff --git a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py b/ergon_core/ergon_core/core/application/jobs/propagate_execution.py similarity index 76% rename from ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py rename to ergon_core/ergon_core/core/application/jobs/propagate_execution.py index daa1034a..23d03da5 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/propagate_execution.py +++ b/ergon_core/ergon_core/core/application/jobs/propagate_execution.py @@ -6,25 +6,22 @@ import logging from datetime import UTC, datetime -import inngest -from ergon_core.core.sandbox.lifecycle import terminate_sandbox_by_id -from ergon_core.core.runtime.events.task_events import ( +from ergon_core.core.application.jobs.models import TaskPropagateResult +from ergon_core.core.application.workflows.orchestration import ( + PropagateTaskCompletionCommand, + WorkflowTerminalState, +) +from ergon_core.core.application.workflows.service import WorkflowService +from ergon_core.core.infrastructure.inngest.client import InngestEvent, inngest_client +from ergon_core.core.infrastructure.sandbox.lifecycle import terminate_sandbox_by_id +from ergon_core.core.application.events.task_events import ( TaskCompletedEvent, TaskFailedEvent, TaskReadyEvent, WorkflowCompletedEvent, WorkflowFailedEvent, ) -from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client -from ergon_core.core.runtime.services.inngest_function_results import TaskPropagateResult -from ergon_core.core.runtime.services.orchestration_dto import ( - PropagateTaskCompletionCommand, - WorkflowTerminalState, -) -from ergon_core.core.runtime.services.task_propagation_service import ( - TaskPropagationService, -) -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, get_trace_sink, task_propagate_context, @@ -33,19 +30,11 @@ logger = logging.getLogger(__name__) -@inngest_client.create_function( - fn_id="task-propagate", - trigger=inngest.TriggerEvent(event="task/completed"), - cancel=RUN_CANCEL, - retries=1, - output_type=TaskPropagateResult, -) -async def propagate_task_fn(ctx: inngest.Context) -> TaskPropagateResult: - payload = TaskCompletedEvent.model_validate(ctx.event.data) +async def run_propagate_task_job(payload: TaskCompletedEvent) -> TaskPropagateResult: logger.info("task-propagate run_id=%s task_id=%s", payload.run_id, payload.task_id) span_start = datetime.now(UTC) - svc = TaskPropagationService() + svc = WorkflowService() propagation = await svc.propagate( PropagateTaskCompletionCommand( run_id=payload.run_id, @@ -56,8 +45,8 @@ async def propagate_task_fn(ctx: inngest.Context) -> TaskPropagateResult: ) ) - events: list[inngest.Event] = [ - inngest.Event( + events: list[InngestEvent] = [ + InngestEvent( name=TaskReadyEvent.name, data=TaskReadyEvent( run_id=payload.run_id, @@ -71,7 +60,7 @@ async def propagate_task_fn(ctx: inngest.Context) -> TaskPropagateResult: if propagation.workflow_terminal_state == WorkflowTerminalState.COMPLETED: events.append( - inngest.Event( + InngestEvent( name=WorkflowCompletedEvent.name, data=WorkflowCompletedEvent( run_id=payload.run_id, @@ -81,7 +70,7 @@ async def propagate_task_fn(ctx: inngest.Context) -> TaskPropagateResult: ) elif propagation.workflow_terminal_state == WorkflowTerminalState.FAILED: events.append( - inngest.Event( + InngestEvent( name=WorkflowFailedEvent.name, data=WorkflowFailedEvent( run_id=payload.run_id, @@ -120,15 +109,7 @@ async def propagate_task_fn(ctx: inngest.Context) -> TaskPropagateResult: return result -@inngest_client.create_function( - fn_id="task-failure-propagate", - trigger=inngest.TriggerEvent(event="task/failed"), - cancel=RUN_CANCEL, - retries=1, - output_type=TaskPropagateResult, -) -async def propagate_task_failure_fn(ctx: inngest.Context) -> TaskPropagateResult: - payload = TaskFailedEvent.model_validate(ctx.event.data) +async def run_propagate_task_failure_job(payload: TaskFailedEvent) -> TaskPropagateResult: logger.info( "task-failure-propagate run_id=%s task_id=%s error=%s", payload.run_id, @@ -136,7 +117,7 @@ async def propagate_task_failure_fn(ctx: inngest.Context) -> TaskPropagateResult payload.error, ) - svc = TaskPropagationService() + svc = WorkflowService() propagation = await svc.propagate_failure( PropagateTaskCompletionCommand( run_id=payload.run_id, @@ -149,11 +130,11 @@ async def propagate_task_failure_fn(ctx: inngest.Context) -> TaskPropagateResult await _terminate_failed_task_sandbox(payload.sandbox_id) # BLOCKED successors are a DB write only — no task/cancelled events. - failure_events: list[inngest.Event] = [] + failure_events: list[InngestEvent] = [] if propagation.workflow_terminal_state == WorkflowTerminalState.FAILED: failure_events.append( - inngest.Event( + InngestEvent( name=WorkflowFailedEvent.name, data=WorkflowFailedEvent( run_id=payload.run_id, diff --git a/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py b/ergon_core/ergon_core/core/application/jobs/run_cleanup.py similarity index 78% rename from ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py rename to ergon_core/ergon_core/core/application/jobs/run_cleanup.py index b8d9fbf1..9196d864 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py +++ b/ergon_core/ergon_core/core/application/jobs/run_cleanup.py @@ -7,15 +7,14 @@ from functools import partial from uuid import UUID -import inngest from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import RunStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.sandbox.lifecycle import terminate_sandbox_by_id -from ergon_core.core.runtime.errors import ConfigurationError, DataIntegrityError -from ergon_core.core.runtime.events.infrastructure_events import RunCleanupEvent -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.inngest_function_results import RunCleanupResult +from ergon_core.core.infrastructure.sandbox.lifecycle import terminate_sandbox_by_id +from ergon_core.core.infrastructure.inngest.errors import ConfigurationError, DataIntegrityError +from ergon_core.core.application.events.infrastructure_events import RunCleanupEvent +from ergon_core.core.application.jobs.models import RunCleanupResult +from typing import Any logger = logging.getLogger(__name__) @@ -26,15 +25,8 @@ } -@inngest_client.create_function( - fn_id="run-cleanup", - trigger=inngest.TriggerEvent(event="run/cleanup"), - retries=0, - output_type=RunCleanupResult, -) -async def run_cleanup_fn(ctx: inngest.Context) -> RunCleanupResult: +async def run_run_cleanup_job(ctx: Any, payload: RunCleanupEvent) -> RunCleanupResult: """Cleanup: terminate sandbox, ensure run status is correct.""" - payload = RunCleanupEvent.model_validate(ctx.event.data) run_id = payload.run_id status = payload.status error_message = payload.error_message diff --git a/ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py b/ergon_core/ergon_core/core/application/jobs/sandbox_setup.py similarity index 71% rename from ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py rename to ergon_core/ergon_core/core/application/jobs/sandbox_setup.py index a785262a..3015fe42 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py +++ b/ergon_core/ergon_core/core/application/jobs/sandbox_setup.py @@ -1,7 +1,7 @@ """Inngest child function: sandbox setup. Creates and configures a sandbox for task execution. -Resolves the sandbox manager from SANDBOX_MANAGERS registry by benchmark_type. +Resolves the sandbox manager from the core component registry. """ import logging @@ -10,50 +10,43 @@ from pathlib import Path from uuid import UUID -import inngest -from ergon_builtins.registry import SANDBOX_MANAGERS +from ergon_core.api.registry import registry from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource -from ergon_core.core.sandbox.manager import BaseSandboxManager, DefaultSandboxManager -from ergon_core.core.runtime.errors import DataIntegrityError -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.child_function_payloads import SandboxSetupRequest -from ergon_core.core.runtime.services.inngest_function_results import SandboxReadyResult -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager, DefaultSandboxManager +from ergon_core.core.infrastructure.inngest.errors import DataIntegrityError +from ergon_core.core.application.jobs.models import SandboxReadyResult, SandboxSetupRequest +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, get_trace_sink, sandbox_setup_context, ) -from ergon_core.core.settings import settings +from ergon_core.core.shared.settings import settings from sqlmodel import col, select +from typing import Any logger = logging.getLogger(__name__) -@inngest_client.create_function( - fn_id="sandbox-setup", - trigger=inngest.TriggerEvent(event="task/sandbox-setup"), - retries=1, - output_type=SandboxReadyResult, -) -async def sandbox_setup_fn(ctx: inngest.Context) -> SandboxReadyResult: +async def run_sandbox_setup_job(ctx: Any, payload: SandboxSetupRequest) -> SandboxReadyResult: """Create and configure a sandbox for task execution.""" - payload = SandboxSetupRequest.model_validate(ctx.event.data) run_id = payload.run_id task_id = payload.task_id benchmark_type = payload.benchmark_type + manager_slug = _sandbox_manager_slug(payload) span_start = datetime.now(UTC) logger.info( - "sandbox-setup run_id=%s task_id=%s benchmark=%s", + "sandbox-setup run_id=%s task_id=%s benchmark=%s sandbox=%s", run_id, task_id, benchmark_type, + manager_slug, ) - # Resolved on demand by benchmark_type (already in payload and - # definition row). Benchmarks not listed get DefaultSandboxManager. - manager_cls = SANDBOX_MANAGERS.get(benchmark_type, DefaultSandboxManager) + # Resolve from the explicit sandbox slug when present. Older payloads + # fall back to benchmark_type for compatibility. + manager_cls = registry.sandbox_managers.get(manager_slug, DefaultSandboxManager) sandbox_manager = manager_cls() output_dir = settings.runs_dir / str(run_id) / "tasks" / str(task_id) @@ -83,6 +76,7 @@ async def sandbox_setup_fn(ctx: inngest.Context) -> SandboxReadyResult: "run_id": str(run_id), "task_id": str(task_id), "benchmark_type": benchmark_type, + "sandbox_slug": manager_slug, "sandbox_id": result.sandbox_id, "input_resource_count": len(payload.input_resource_ids), }, @@ -91,6 +85,10 @@ async def sandbox_setup_fn(ctx: inngest.Context) -> SandboxReadyResult: return result +def _sandbox_manager_slug(payload: SandboxSetupRequest) -> str: + return payload.sandbox_slug or payload.benchmark_type + + async def _create_sandbox( run_id: UUID, task_id: UUID, diff --git a/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py b/ergon_core/ergon_core/core/application/jobs/start_workflow.py similarity index 87% rename from ergon_core/ergon_core/core/runtime/inngest/start_workflow.py rename to ergon_core/ergon_core/core/application/jobs/start_workflow.py index 1969ab5c..d9bba2bd 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/start_workflow.py +++ b/ergon_core/ergon_core/core/application/jobs/start_workflow.py @@ -4,23 +4,20 @@ from datetime import UTC, datetime from uuid import UUID, uuid4, uuid5 -import inngest -from ergon_core.core.dashboard.event_contracts import TaskTreeNode, WorkerRef -from ergon_core.core.dashboard.provider import get_dashboard_emitter +from ergon_core.core.infrastructure.dashboard.event_contracts import TaskTreeNode, WorkerRef +from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter from ergon_core.core.persistence.definitions.models import ExperimentDefinitionWorker from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.runtime.events.task_events import ( +from ergon_core.core.application.events.task_events import ( TaskReadyEvent, WorkflowStartedEvent, ) -from ergon_core.core.runtime.inngest.client import RUN_CANCEL, inngest_client -from ergon_core.core.runtime.services.inngest_function_results import WorkflowStartResult -from ergon_core.core.runtime.services.orchestration_dto import InitializeWorkflowCommand -from ergon_core.core.runtime.services.workflow_initialization_service import ( - WorkflowInitializationService, -) -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.infrastructure.inngest.client import InngestEvent, inngest_client +from ergon_core.core.application.jobs.models import WorkflowStartResult +from ergon_core.core.application.workflows.orchestration import InitializeWorkflowCommand +from ergon_core.core.application.workflows.service import WorkflowService +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, get_trace_sink, workflow_start_context, @@ -142,19 +139,11 @@ def build(node_id: UUID) -> TaskTreeNode: ) -@inngest_client.create_function( - fn_id="workflow-start", - trigger=inngest.TriggerEvent(event="workflow/started"), - cancel=RUN_CANCEL, - retries=1, - output_type=WorkflowStartResult, -) -async def start_workflow_fn(ctx: inngest.Context) -> WorkflowStartResult: - payload = WorkflowStartedEvent.model_validate(ctx.event.data) +async def run_start_workflow_job(payload: WorkflowStartedEvent) -> WorkflowStartResult: logger.info("workflow-start run_id=%s definition_id=%s", payload.run_id, payload.definition_id) span_start = datetime.now(UTC) - svc = WorkflowInitializationService() + svc = WorkflowService() initialized = await svc.initialize( InitializeWorkflowCommand( run_id=payload.run_id, @@ -163,7 +152,7 @@ async def start_workflow_fn(ctx: inngest.Context) -> WorkflowStartResult: ) events = [ - inngest.Event( + InngestEvent( name=TaskReadyEvent.name, data=TaskReadyEvent( run_id=payload.run_id, diff --git a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py b/ergon_core/ergon_core/core/application/jobs/worker_execute.py similarity index 60% rename from ergon_core/ergon_core/core/runtime/inngest/worker_execute.py rename to ergon_core/ergon_core/core/application/jobs/worker_execute.py index 58a2edfe..8148aa9f 100644 --- a/ergon_core/ergon_core/core/runtime/inngest/worker_execute.py +++ b/ergon_core/ergon_core/core/application/jobs/worker_execute.py @@ -1,29 +1,28 @@ """Inngest child function: worker execution. -Looks up the registered worker, constructs a BenchmarkTask, and runs execute(). +Looks up the registered worker, constructs a Task, and runs execute(). Consumes the async generator, persisting context events to PG via the -ContextEventRepository. Dashboard events are emitted per chunk via the +ContextEventService. Dashboard events are emitted per chunk via the repository listener pattern. """ import logging import traceback +from collections.abc import AsyncIterable, Awaitable, Callable from datetime import UTC, datetime -import inngest -from ergon_builtins.registry import BENCHMARKS, WORKERS -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.api.worker_context import WorkerContext -from ergon_core.core.dashboard.provider import get_dashboard_emitter -from ergon_core.core.generation import ContextPartChunk -from ergon_core.core.persistence.context.repository import ContextEventRepository -from ergon_core.core.persistence.queries import queries +from ergon_core.api.benchmark import EmptyTaskPayload, Task +from ergon_core.api.registry import registry +from ergon_core.api.worker import WorkerContext, WorkerOutput, WorkerStreamItem +from ergon_core.core.application.experiments.repository import DefinitionRepository +from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter +from ergon_core.core.domain.generation.context_parts import ContextPartChunk from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.runtime.errors import RegistryLookupError -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.child_function_payloads import WorkerExecuteRequest -from ergon_core.core.runtime.services.inngest_function_results import WorkerExecuteResult -from ergon_core.core.runtime.tracing import ( +from ergon_core.core.application.context.events import ContextEventService +from ergon_core.core.infrastructure.inngest.errors import ContractViolationError, RegistryLookupError +from ergon_core.core.application.jobs.models import WorkerExecuteJobRequest +from ergon_core.core.application.jobs.models import WorkerExecuteJobResult +from ergon_core.core.infrastructure.tracing import ( CompletedSpan, get_trace_sink, worker_execute_context, @@ -33,14 +32,7 @@ logger = logging.getLogger(__name__) -@inngest_client.create_function( - fn_id="worker-execute", - trigger=inngest.TriggerEvent(event="task/worker-execute"), - retries=0, - output_type=WorkerExecuteResult, -) -async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: - payload = WorkerExecuteRequest.model_validate(ctx.event.data) +async def run_worker_execute_job(payload: WorkerExecuteJobRequest) -> WorkerExecuteJobResult: logger.info( "worker-execute run_id=%s task_id=%s worker_type=%s", payload.run_id, @@ -49,7 +41,7 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: ) span_start = datetime.now(UTC) - worker_cls = WORKERS.get(payload.worker_type) + worker_cls = registry.workers.get(payload.worker_type) if worker_cls is None: raise RegistryLookupError( registry_name="worker", @@ -70,13 +62,17 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: task_payload = None instance_key = str(payload.execution_id) if payload.task_id is not None: - task_row, instance_row = queries.definitions.get_task_with_instance(payload.task_id) - benchmark_cls = BENCHMARKS.get(payload.benchmark_type) + with get_session() as session: + task_row, instance_row = DefinitionRepository().task_with_instance( + session, + payload.task_id, + ) + benchmark_cls = registry.benchmarks.get(payload.benchmark_type) if benchmark_cls is not None: task_payload = task_row.task_payload_as(benchmark_cls.task_payload_model) instance_key = instance_row.instance_key - task = BenchmarkTask[BaseModel]( + task = Task[BaseModel]( task_slug=payload.task_slug, instance_key=instance_key, description=payload.task_description, @@ -92,7 +88,7 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: node_id=payload.node_id, ) - context_event_repo = ContextEventRepository() + context_event_repo = ContextEventService() dashboard_emitter = get_dashboard_emitter() context_event_repo.add_listener(dashboard_emitter.on_context_event) dashboard_emitter.register_execution( @@ -102,16 +98,15 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: chunk_count = 0 try: - async for chunk in worker.execute(task, context=worker_context): - await _persist_context_events( + output, chunk_count = await _consume_worker_stream( + worker.execute(task, context=worker_context), + lambda chunk, count: _persist_context_events( context_event_repo, payload, chunk, - chunk_count, - ) - chunk_count += 1 - - output = worker.get_output(worker_context) + count, + ), + ) except Exception as exc: # slopcop: ignore[no-broad-except] error_msg = str(exc) @@ -121,7 +116,7 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: chunk_count, error_msg, ) - return WorkerExecuteResult( + return WorkerExecuteJobResult( success=False, error=error_msg, error_json={ @@ -158,16 +153,48 @@ async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: ) ) - return WorkerExecuteResult( + return WorkerExecuteJobResult( success=output.success, final_assistant_message=output.output, error=None if output.success else output.output, ) +async def _consume_worker_stream( + stream: AsyncIterable[WorkerStreamItem], + persist_chunk: Callable[[ContextPartChunk, int], Awaitable[None]], +) -> tuple[WorkerOutput, int]: + """Persist context chunks and return the terminal worker output.""" + output: WorkerOutput | None = None + chunk_count = 0 + + async for item in stream: + if isinstance(item, WorkerOutput): + if output is not None: + raise ContractViolationError("Worker emitted multiple terminal WorkerOutput items") + output = item + continue + + if output is not None: + raise ContractViolationError("Worker emitted context chunk after terminal WorkerOutput") + + if not isinstance(item, ContextPartChunk): + raise ContractViolationError( + f"Worker stream expected ContextPartChunk or WorkerOutput, got {type(item).__name__}" + ) + + await persist_chunk(item, chunk_count) + chunk_count += 1 + + if output is None: + raise ContractViolationError("Worker stream ended without terminal WorkerOutput") + + return output, chunk_count + + async def _persist_context_events( - context_event_repo: ContextEventRepository, - payload: WorkerExecuteRequest, + context_event_repo: ContextEventService, + payload: WorkerExecuteJobRequest, chunk: ContextPartChunk, chunk_count: int, ) -> None: diff --git a/ergon_core/ergon_core/core/runtime/inngest/client.py b/ergon_core/ergon_core/core/runtime/inngest/client.py deleted file mode 100644 index d69c474c..00000000 --- a/ergon_core/ergon_core/core/runtime/inngest/client.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Inngest client singleton and shared configuration.""" - -import inngest -from ergon_core.core.settings import settings - -inngest_client = inngest.Inngest( - app_id="ergon-core", - event_key=settings.inngest_event_key or "local-dev", - is_production=not settings.inngest_dev, - api_base_url=settings.inngest_api_base_url, - event_api_base_url=settings.inngest_api_base_url, - serializer=inngest.PydanticSerializer(), -) - -# All orchestration functions carry run_id in their trigger event data. -# Sending a run/cancelled event with a matching run_id kills them in-flight. -RUN_CANCEL = [ - inngest.Cancel( - event="run/cancelled", - if_exp="event.data.run_id == async.data.run_id", - ) -] - -# Per-node cancel matcher. Fires on task/cancelled for this exact node_id. -# Used by execute_task_fn to drop queued or terminate in-flight invocations -# when a parent terminates or the manager explicitly cancels. -TASK_CANCEL = [ - inngest.Cancel( - event="task/cancelled", - if_exp="event.data.node_id == async.data.node_id", - ), -] diff --git a/ergon_core/ergon_core/core/runtime/inngest/registry.py b/ergon_core/ergon_core/core/runtime/inngest/registry.py deleted file mode 100644 index 18258830..00000000 --- a/ergon_core/ergon_core/core/runtime/inngest/registry.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Central registry of all Inngest functions for the ergon-core app. - -Pass ALL_FUNCTIONS to inngest.serve() or the framework integration. -""" - -from ergon_core.core.runtime.inngest.cancel_orphan_subtasks import ( - block_descendants_on_failed_fn, - cancel_orphans_on_cancelled_fn, -) -from ergon_core.core.runtime.inngest.check_evaluators import check_and_run_evaluators -from ergon_core.core.runtime.inngest.cleanup_cancelled_task import cleanup_cancelled_task_fn -from ergon_core.core.runtime.inngest.complete_workflow import complete_workflow_fn -from ergon_core.core.runtime.inngest.evaluate_task_run import evaluate_task_run -from ergon_core.core.runtime.inngest.execute_task import execute_task_fn -from ergon_core.core.runtime.inngest.fail_workflow import fail_workflow_fn -from ergon_core.core.runtime.inngest.persist_outputs import persist_outputs_fn -from ergon_core.core.runtime.inngest.propagate_execution import ( - propagate_task_failure_fn, - propagate_task_fn, -) -from ergon_core.core.runtime.inngest.run_cleanup import run_cleanup_fn -from ergon_core.core.runtime.inngest.sandbox_setup import sandbox_setup_fn -from ergon_core.core.runtime.inngest.start_workflow import start_workflow_fn -from ergon_core.core.runtime.inngest.worker_execute import worker_execute_fn - -ALL_FUNCTIONS = [ - # Task orchestration - start_workflow_fn, - execute_task_fn, - propagate_task_fn, - propagate_task_failure_fn, - complete_workflow_fn, - fail_workflow_fn, - # Task child functions - sandbox_setup_fn, - worker_execute_fn, - persist_outputs_fn, - # Evaluation - check_and_run_evaluators, - evaluate_task_run, - # Subtask lifecycle - block_descendants_on_failed_fn, - cancel_orphans_on_cancelled_fn, - cleanup_cancelled_task_fn, - # Infrastructure - run_cleanup_fn, -] From 8172aba0eade73a092b927424050681b70dc2d40 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:45 +0100 Subject: [PATCH 55/66] refactor: move read models out of runtime services Made-with: Cursor --- .../core/application/read_models/__init__.py | 1 + .../core/application/read_models/cohorts.py | 342 ++++++++++++++++ .../core/application/read_models/errors.py | 17 + .../read_models/experiments.py} | 0 .../core/application/read_models/models.py | 296 ++++++++++++++ .../core/application/read_models/resources.py | 10 + .../application/read_models/run_snapshot.py | 382 ++++++++++++++++++ .../read_models/runs.py} | 48 +-- .../core/runtime/services/run_service.py | 103 ----- 9 files changed, 1073 insertions(+), 126 deletions(-) create mode 100644 ergon_core/ergon_core/core/application/read_models/__init__.py create mode 100644 ergon_core/ergon_core/core/application/read_models/cohorts.py create mode 100644 ergon_core/ergon_core/core/application/read_models/errors.py rename ergon_core/ergon_core/core/{runtime/services/experiment_read_service.py => application/read_models/experiments.py} (100%) create mode 100644 ergon_core/ergon_core/core/application/read_models/models.py create mode 100644 ergon_core/ergon_core/core/application/read_models/resources.py create mode 100644 ergon_core/ergon_core/core/application/read_models/run_snapshot.py rename ergon_core/ergon_core/core/{runtime/services/run_read_service.py => application/read_models/runs.py} (89%) delete mode 100644 ergon_core/ergon_core/core/runtime/services/run_service.py diff --git a/ergon_core/ergon_core/core/application/read_models/__init__.py b/ergon_core/ergon_core/core/application/read_models/__init__.py new file mode 100644 index 00000000..6abe02e1 --- /dev/null +++ b/ergon_core/ergon_core/core/application/read_models/__init__.py @@ -0,0 +1 @@ +"""Application read models.""" diff --git a/ergon_core/ergon_core/core/application/read_models/cohorts.py b/ergon_core/ergon_core/core/application/read_models/cohorts.py new file mode 100644 index 00000000..f49e183d --- /dev/null +++ b/ergon_core/ergon_core/core/application/read_models/cohorts.py @@ -0,0 +1,342 @@ +"""Application service for experiment cohort queries and resolution.""" + +from collections import Counter +from dataclasses import dataclass, field +from uuid import UUID + +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.persistence.shared.enums import RunStatus +from ergon_core.core.persistence.telemetry.evaluation_summary import EvaluationSummary +from ergon_core.core.persistence.telemetry.models import ( + ExperimentCohort, + ExperimentCohortStats, + ExperimentCohortStatus, + ExperimentRecord, + RunRecord, +) +from ergon_core.core.application.read_models.models import ( + CohortDetailDto, + CohortExperimentRowDto, + CohortStatusCountsDto, + CohortSummaryDto, + UpdateCohortRequest, +) +from ergon_core.core.shared.utils import utcnow +from sqlmodel import select + + +@dataclass(frozen=True) +class RubricStatusSummary: + status: str + total_criteria: int + passed: int = 0 + failed: int = 0 + errored: int = 0 + skipped: int = 0 + criterion_statuses: list[str] = field(default_factory=list) + evaluator_names: list[str] = field(default_factory=list) + + +class ExperimentCohortService: + """Resolve cohorts and assemble frontend-facing cohort DTOs.""" + + def resolve_or_create( + self, + name: str, + description: str | None = None, + created_by: str | None = None, + ) -> ExperimentCohort: + """Resolve an existing cohort by name or create a new one.""" + with get_session() as session: + stmt = select(ExperimentCohort).where(ExperimentCohort.name == name) + existing = session.exec(stmt).first() + if existing is not None: + return existing + + cohort = ExperimentCohort( + name=name, + description=description, + created_by=created_by, + ) + session.add(cohort) + session.commit() + session.refresh(cohort) + return cohort + + def list_summaries(self, *, include_archived: bool = False) -> list[CohortSummaryDto]: + """List all cohorts as summary DTOs.""" + with get_session() as session: + stmt = select(ExperimentCohort) + if not include_archived: + stmt = stmt.where(ExperimentCohort.status != ExperimentCohortStatus.ARCHIVED) + cohorts = list(session.exec(stmt).all()) + + results: list[CohortSummaryDto] = [] + for cohort in cohorts: + stats = session.exec( + select(ExperimentCohortStats).where( + ExperimentCohortStats.cohort_id == cohort.id + ) + ).first() + results.append(self._build_summary(cohort, stats)) + return results + + def get_detail(self, cohort_id: UUID) -> CohortDetailDto | None: + """Get a cohort detail DTO with all experiments in the project folder.""" + with get_session() as session: + cohort = session.get(ExperimentCohort, cohort_id) + if cohort is None: + return None + + stats = session.exec( + select(ExperimentCohortStats).where(ExperimentCohortStats.cohort_id == cohort_id) + ).first() + summary = self._build_summary(cohort, stats) + + experiments = list( + session.exec( + select(ExperimentRecord).where(ExperimentRecord.cohort_id == cohort_id) + ).all() + ) + experiment_rows = [ + self._build_experiment_row( + experiment, + list( + session.exec( + select(RunRecord).where(RunRecord.experiment_id == experiment.id) + ).all() + ), + ) + for experiment in experiments + ] + return CohortDetailDto(summary=summary, experiments=experiment_rows) + + def get_summary(self, cohort_id: UUID) -> CohortSummaryDto | None: + """Get a single cohort summary DTO.""" + with get_session() as session: + cohort = session.get(ExperimentCohort, cohort_id) + if cohort is None: + return None + stats = session.exec( + select(ExperimentCohortStats).where(ExperimentCohortStats.cohort_id == cohort_id) + ).first() + return self._build_summary(cohort, stats) + + def cohort_id_for_run(self, run_id: UUID) -> UUID | None: + """Return the owning cohort for a run, if one exists.""" + with get_session() as session: + run = session.get(RunRecord, run_id) + if run is None or run.experiment_id is None: + return None + experiment = session.get(ExperimentRecord, run.experiment_id) + return experiment.cohort_id if experiment is not None else None + + def update_cohort( + self, cohort_id: UUID, request: UpdateCohortRequest + ) -> CohortSummaryDto | None: + """Update mutable operator-facing cohort properties.""" + with get_session() as session: + cohort = session.get(ExperimentCohort, cohort_id) + if cohort is None: + return None + + cohort.status = request.status.value + cohort.updated_at = utcnow() + session.add(cohort) + session.commit() + session.refresh(cohort) + + stats = session.exec( + select(ExperimentCohortStats).where(ExperimentCohortStats.cohort_id == cohort_id) + ).first() + return self._build_summary(cohort, stats) + + def recompute(self, cohort_id: UUID) -> None: + """Recompute and persist aggregate stats for one cohort.""" + with get_session() as session: + runs = list( + session.exec( + select(RunRecord) + .join(ExperimentRecord) + .where(ExperimentRecord.cohort_id == cohort_id) + ).all() + ) + status_counts = Counter(run.status for run in runs) + scored_values = [s for s in (_score_value(run) for run in runs) if s is not None] + durations_ms = [ + int((run.completed_at - run.started_at).total_seconds() * 1000) + for run in runs + if run.started_at is not None and run.completed_at is not None + ] + total_runs = len(runs) + failed_runs = status_counts.get(RunStatus.FAILED, 0) + average_score = (sum(scored_values) / len(scored_values)) if scored_values else None + average_duration_ms = ( + (sum(durations_ms) // len(durations_ms)) if durations_ms else None + ) + + stats = session.exec( + select(ExperimentCohortStats).where(ExperimentCohortStats.cohort_id == cohort_id) + ).first() + if stats is None: + stats = ExperimentCohortStats(cohort_id=cohort_id) + + stats.total_runs = total_runs + stats.completed_runs = status_counts.get(RunStatus.COMPLETED, 0) + stats.failed_runs = failed_runs + stats.average_score = average_score + stats.best_score = max(scored_values) if scored_values else None + stats.worst_score = min(scored_values) if scored_values else None + stats.average_duration_ms = average_duration_ms + stats.failure_rate = (failed_runs / total_runs) if total_runs else 0.0 + stats.updated_at = utcnow() + session.add(stats) + session.commit() + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + @staticmethod + def _build_summary( + cohort: ExperimentCohort, + stats: ExperimentCohortStats | None, + ) -> CohortSummaryDto: + return CohortSummaryDto( + cohort_id=cohort.id, + name=cohort.name, + description=cohort.description, + created_by=cohort.created_by, + created_at=cohort.created_at, + status=cohort.status, + total_runs=stats.total_runs if stats else 0, + status_counts=CohortStatusCountsDto( + completed=stats.completed_runs if stats else 0, + failed=stats.failed_runs if stats else 0, + ), + average_score=stats.average_score if stats else None, + best_score=stats.best_score if stats else None, + worst_score=stats.worst_score if stats else None, + average_duration_ms=stats.average_duration_ms if stats else None, + failure_rate=stats.failure_rate if stats else 0.0, + stats_updated_at=stats.updated_at if stats else None, + ) + + @staticmethod + def _build_experiment_row( + experiment: ExperimentRecord, + runs: list[RunRecord], + ) -> CohortExperimentRowDto: + score: float | None = None + total_cost_usd: float | None = None + for run in runs: + summary = run.parsed_summary() + raw_score = summary.get("normalized_score") + if raw_score is None: + raw_score = summary.get("final_score") + if isinstance(raw_score, int | float): + score = float(raw_score) + raw_cost = summary.get("total_cost_usd") + if isinstance(raw_cost, int | float): + total_cost_usd = (total_cost_usd or 0.0) + float(raw_cost) + + status_counts = CohortStatusCountsDto() + for run in runs: + _increment_status_count(status_counts, str(run.status)) + + return CohortExperimentRowDto( + experiment_id=experiment.id, + name=experiment.name, + benchmark_type=experiment.benchmark_type, + sample_count=experiment.sample_count, + total_runs=len(runs), + status_counts=status_counts, + status=_experiment_row_status(experiment.status, status_counts, len(runs)), + created_at=experiment.created_at, + default_model_target=experiment.default_model_target, + default_evaluator_slug=experiment.default_evaluator_slug, + final_score=score, + total_cost_usd=total_cost_usd, + error_message=None, + ) + + +def _increment_status_count(counts: CohortStatusCountsDto, status: str) -> None: + match status: + case "pending": + counts.pending += 1 + case "executing": + counts.executing += 1 + case "evaluating": + counts.evaluating += 1 + case "completed": + counts.completed += 1 + case "failed": + counts.failed += 1 + + +def _score_value(run: RunRecord) -> float | None: + """Choose the score field used for cohort aggregates.""" + summary = run.parsed_summary() + if not summary: + return None + norm = summary.get("normalized_score") + if norm is not None: + return float(norm) + final = summary.get("final_score") + if final is not None: + return float(final) + return None + + +def _rubric_status_summary(summaries: list[EvaluationSummary]) -> RubricStatusSummary: + statuses: list[str] = [] + evaluator_names: list[str] = [] + for summary in summaries: + evaluator_names.append(summary.evaluator_name) + statuses.extend(result.status for result in summary.criterion_results) + + passed = statuses.count("passed") + failed = statuses.count("failed") + errored = statuses.count("errored") + skipped = statuses.count("skipped") + status = "none" + if errored: + status = "errored" + elif failed: + status = "failing" + elif passed: + status = "passing" + + return RubricStatusSummary( + status=status, + total_criteria=len(statuses), + passed=passed, + failed=failed, + errored=errored, + skipped=skipped, + criterion_statuses=statuses, + evaluator_names=evaluator_names, + ) + + +def _experiment_row_status( + experiment_status: str, + counts: CohortStatusCountsDto, + total_runs: int, +) -> str: + if total_runs == 0: + return experiment_status + active_runs = counts.pending + counts.executing + counts.evaluating + if active_runs > 0: + return experiment_status + if counts.failed == total_runs: + return "failed" + if counts.completed == total_runs: + return "completed" + if counts.failed > 0 and counts.completed > 0: + return "completed_with_failures" + return experiment_status + + +experiment_cohort_service = ExperimentCohortService() diff --git a/ergon_core/ergon_core/core/application/read_models/errors.py b/ergon_core/ergon_core/core/application/read_models/errors.py new file mode 100644 index 00000000..7d2f7e41 --- /dev/null +++ b/ergon_core/ergon_core/core/application/read_models/errors.py @@ -0,0 +1,17 @@ +"""Read-model errors.""" + + +class ReadModelError(Exception): + """Base for read-model failures.""" + + +class ResourceTooLargeError(ReadModelError): + """A resource blob is too large for inline viewing.""" + + def __init__(self, size_bytes: int, limit_bytes: int) -> None: + super().__init__( + f"Resource content {size_bytes} bytes exceeds viewer limit ({limit_bytes} bytes)" + ) + self.size_bytes = size_bytes + self.limit_bytes = limit_bytes + diff --git a/ergon_core/ergon_core/core/runtime/services/experiment_read_service.py b/ergon_core/ergon_core/core/application/read_models/experiments.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/services/experiment_read_service.py rename to ergon_core/ergon_core/core/application/read_models/experiments.py diff --git a/ergon_core/ergon_core/core/application/read_models/models.py b/ergon_core/ergon_core/core/application/read_models/models.py new file mode 100644 index 00000000..e2ee594f --- /dev/null +++ b/ergon_core/ergon_core/core/application/read_models/models.py @@ -0,0 +1,296 @@ +"""Pydantic DTOs for the run detail API surface. + +Task structure comes from RunGraphNode + RunGraphEdge rows (the live graph), +not from ExperimentDefinitionTask. All task keys are RunGraphNode.id. + +""" + +from datetime import datetime +from typing import Any +from uuid import UUID + +from ergon_core.core.application.communication.models import RunCommunicationThreadDto +from ergon_core.core.application.graph.models import GraphMutationRecordDto +from ergon_core.core.persistence.context.event_payloads import ( + ContextEventPayload, + ContextEventType, +) +from ergon_core.core.persistence.telemetry.evaluation_summary import EvalCriterionStatus +from ergon_core.core.persistence.telemetry.models import ExperimentCohortStatus +from ergon_core.core.shared.json_types import JsonObject +from pydantic import BaseModel, ConfigDict, Field + + +def _to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +class CamelModel(BaseModel): + """Base model that exposes camelCase JSON to the frontend.""" + + model_config = ConfigDict( + alias_generator=_to_camel, + populate_by_name=True, + extra="forbid", + ) + + +class RunTaskDto(CamelModel): + """REST projection of RunGraphNode for run detail pages. + + This is not the canonical graph schema; graph semantics live in + application/graph/models.py and persistence/graph/status_conventions.py. + """ + + id: str + name: str + description: str + status: str + parent_id: str | None = None + child_ids: list[str] = Field(default_factory=list) + depends_on_ids: list[str] = Field(default_factory=list) + is_leaf: bool + level: int + assigned_worker_id: str | None = None + assigned_worker_slug: str | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + + +class RunResourceDto(CamelModel): + id: str + task_id: str + task_execution_id: str + name: str + mime_type: str + file_path: str + size_bytes: int + created_at: datetime + + +class RunExecutionAttemptDto(CamelModel): + id: str + task_id: str + attempt_number: int + status: str + started_at: datetime | None = None + completed_at: datetime | None = None + final_assistant_message: str | None = None + error_message: str | None = None + score: float | None = None + agent_id: str | None = None + agent_name: str | None = None + evaluation_details: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] + output_resource_ids: list[str] = Field(default_factory=list) + + +class RunEvaluationCriterionDto(CamelModel): + id: str + stage_num: int + stage_name: str + criterion_num: int + criterion_slug: str + criterion_type: str + criterion_description: str + criterion_name: str + status: EvalCriterionStatus + passed: bool + weight: float + contribution: float + evaluation_input: str | None = None + score: float + max_score: float + feedback: str | None = None + model_reasoning: str | None = None + skipped_reason: str | None = None + evaluated_action_ids: list[str] = Field(default_factory=list) + evaluated_resource_ids: list[str] = Field(default_factory=list) + observation: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] + error: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] + + +class RunTaskEvaluationDto(CamelModel): + id: str + run_id: str + task_id: str | None = None + evaluator_name: str + aggregation_rule: str + total_score: float + max_score: float + normalized_score: float + stages_evaluated: int + stages_passed: int + failed_gate: str | None = None + created_at: datetime + criterion_results: list[RunEvaluationCriterionDto] = Field(default_factory=list) + + +class RunSandboxCommandDto(CamelModel): + command: str + stdout: str | None = None + stderr: str | None = None + exit_code: int | None = None + duration_ms: int | None = None + timestamp: datetime + + +class RunSandboxDto(CamelModel): + sandbox_id: str + task_id: str + template: str | None = None + timeout_minutes: int + status: str + created_at: datetime + closed_at: datetime | None = None + close_reason: str | None = None + commands: list[RunSandboxCommandDto] = Field(default_factory=list) + + +class RunContextEventDto(CamelModel): + id: UUID + run_id: UUID + task_execution_id: UUID + task_node_id: UUID + worker_binding_key: str + sequence: int + event_type: ContextEventType + payload: ContextEventPayload + created_at: datetime + started_at: datetime | None = None + completed_at: datetime | None = None + + +class RunSnapshotDto(CamelModel): + id: str + experiment_id: str + name: str + status: str + tasks: dict[str, RunTaskDto] = Field(default_factory=dict) + root_task_id: str = "" # slopcop: ignore[no-str-empty-default] + resources_by_task: dict[str, list[RunResourceDto]] = Field(default_factory=dict) + executions_by_task: dict[str, list[RunExecutionAttemptDto]] = Field(default_factory=dict) + evaluations_by_task: dict[str, RunTaskEvaluationDto] = Field(default_factory=dict) + sandboxes_by_task: dict[str, RunSandboxDto] = Field(default_factory=dict) + context_events_by_task: dict[str, list[RunContextEventDto]] = Field(default_factory=dict) + threads: list[RunCommunicationThreadDto] = Field(default_factory=list) + started_at: datetime | None = None + completed_at: datetime | None = None + duration_seconds: float | None = None + total_tasks: int = 0 + total_leaf_tasks: int = 0 + completed_tasks: int = 0 + failed_tasks: int = 0 + running_tasks: int = 0 + cancelled_tasks: int = 0 + final_score: float | None = None + error: str | None = None + + +# --------------------------------------------------------------------------- +# Training DTOs (RL observability) +# --------------------------------------------------------------------------- + + +class TrainingCurvePointDto(CamelModel): + run_id: str + step: int + mean_score: float + benchmark_type: str | None = None + created_at: str | None = None + + +class TrainingSessionDto(CamelModel): + id: str + experiment_definition_id: str + model_name: str + status: str + started_at: str | None = None + completed_at: str | None = None + output_dir: str | None = None + total_steps: int | None = None + final_loss: float | None = None + + +class TrainingMetricDto(CamelModel): + step: int + epoch: float | None = None + loss: float | None = None + grad_norm: float | None = None + learning_rate: float | None = None + reward_mean: float | None = None + reward_std: float | None = None + entropy: float | None = None + completion_mean_length: float | None = None + step_time_s: float | None = None + + +class CohortStatusCountsDto(BaseModel): + """Aggregate run counts by lifecycle status.""" + + pending: int = 0 + executing: int = 0 + evaluating: int = 0 + completed: int = 0 + failed: int = 0 + + +class CohortSummaryDto(BaseModel): + """Summary row for cohort list and live updates.""" + + cohort_id: UUID + name: str + description: str | None = None + created_by: str | None = None + created_at: datetime + status: str + total_runs: int = 0 + status_counts: CohortStatusCountsDto = Field(default_factory=CohortStatusCountsDto) + average_score: float | None = None + best_score: float | None = None + worst_score: float | None = None + average_duration_ms: int | None = None + failure_rate: float = 0.0 + stats_updated_at: datetime | None = None + + +class CohortExperimentRowDto(BaseModel): + """One experiment inside a cohort detail view.""" + + experiment_id: UUID + name: str + benchmark_type: str + sample_count: int + total_runs: int = 0 + status_counts: CohortStatusCountsDto = Field(default_factory=CohortStatusCountsDto) + status: str + created_at: datetime + default_model_target: str | None = None + default_evaluator_slug: str | None = None + final_score: float | None = None + total_cost_usd: float | None = None + error_message: str | None = None + + +class CohortDetailDto(BaseModel): + """Full payload for a single cohort detail page.""" + + summary: CohortSummaryDto + experiments: list[CohortExperimentRowDto] = Field(default_factory=list) + + +class UpdateCohortRequest(BaseModel): + """Mutable cohort fields exposed through the operator API.""" + + status: ExperimentCohortStatus + + +class ResolveCohortRequest(BaseModel): + """Request to resolve or create a cohort by name.""" + + name: str + description: str | None = None + created_by: str | None = None + metadata: JsonObject = Field(default_factory=dict) + + diff --git a/ergon_core/ergon_core/core/application/read_models/resources.py b/ergon_core/ergon_core/core/application/read_models/resources.py new file mode 100644 index 00000000..34b0406a --- /dev/null +++ b/ergon_core/ergon_core/core/application/read_models/resources.py @@ -0,0 +1,10 @@ +"""Resource read-model limits and guards.""" + +from ergon_core.core.application.read_models.errors import ResourceTooLargeError + +RESOURCE_CONTENT_MAX_BYTES: int = 10 * 1024 * 1024 + + +def require_viewable_resource_size(size_bytes: int) -> None: + if size_bytes > RESOURCE_CONTENT_MAX_BYTES: + raise ResourceTooLargeError(size_bytes, RESOURCE_CONTENT_MAX_BYTES) diff --git a/ergon_core/ergon_core/core/application/read_models/run_snapshot.py b/ergon_core/ergon_core/core/application/read_models/run_snapshot.py new file mode 100644 index 00000000..598d83cf --- /dev/null +++ b/ergon_core/ergon_core/core/application/read_models/run_snapshot.py @@ -0,0 +1,382 @@ +"""Pure read-model helpers for persisted run snapshots.""" + +from collections import defaultdict +from datetime import datetime +from uuid import UUID + +from ergon_core.core.application.communication.models import ( + RunCommunicationMessageDto, + RunCommunicationThreadDto, +) +from ergon_core.core.application.read_models.models import ( + RunContextEventDto, + RunEvaluationCriterionDto, + RunExecutionAttemptDto, + RunResourceDto, + RunSandboxCommandDto, + RunSandboxDto, + RunTaskDto, + RunTaskEvaluationDto, +) +from ergon_core.core.persistence.context.models import RunContextEvent +from ergon_core.core.persistence.definitions.models import ExperimentDefinitionWorker +from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphNode +from ergon_core.core.persistence.telemetry.models import ( + RunResource, + RunTaskEvaluation, + RunTaskExecution, + Thread, + ThreadMessage, +) + + +def _build_task_map( + nodes: list[RunGraphNode], + edges: list[RunGraphEdge], + worker_by_binding: dict[str, ExperimentDefinitionWorker], + task_timestamps: dict[UUID, tuple[datetime | None, datetime | None]], +) -> tuple[dict[str, RunTaskDto], str, int, int, int, int, int, int]: + """Three clean passes using stored containment columns. + + Pass 1: node columns (parent_node_id, level) - no edge traversal. + Pass 2: reverse lookup for child_ids and is_leaf. + Pass 3: dependency edges -> depends_on_ids. + """ + if not nodes: + return {}, "", 0, 0, 0, 0, 0, 0 + + task_map: dict[str, RunTaskDto] = {} + + for node in nodes: + nid = str(node.id) + worker = ( + worker_by_binding.get(node.assigned_worker_slug) + if node.assigned_worker_slug is not None + else None + ) + started_at, completed_at = task_timestamps.get(node.id, (None, None)) + task_map[nid] = RunTaskDto( + id=nid, + name=node.task_slug, + description=node.description, + status=node.status, + parent_id=str(node.parent_node_id) if node.parent_node_id else None, + child_ids=[], + depends_on_ids=[], + is_leaf=True, + level=node.level, + assigned_worker_id=str(worker.id) if worker else None, + assigned_worker_slug=node.assigned_worker_slug, + started_at=started_at, + completed_at=completed_at, + ) + + for nid, dto in task_map.items(): + if dto.parent_id and dto.parent_id in task_map: + parent = task_map[dto.parent_id] + task_map[dto.parent_id] = parent.model_copy( + update={"child_ids": [*parent.child_ids, nid], "is_leaf": False} + ) + + for edge in edges: + src, tgt = str(edge.source_node_id), str(edge.target_node_id) + target_task = task_map.get(tgt) + if target_task is None: + continue + task_map[tgt] = target_task.model_copy( + update={"depends_on_ids": [*target_task.depends_on_ids, src]} + ) + + root_id = next((t.id for t in task_map.values() if t.parent_id is None), "") + total = len(task_map) + leaves = [t for t in task_map.values() if t.is_leaf] + total_leaf = len(leaves) + completed = sum(1 for t in leaves if t.status == "completed") + failed = sum(1 for t in leaves if t.status == "failed") + running = sum(1 for t in leaves if t.status == "running") + cancelled = sum(1 for t in leaves if t.status == "cancelled") + + return task_map, root_id, total, total_leaf, completed, failed, running, cancelled + + +def _task_keyed_executions( + executions: list[RunTaskExecution], + worker_map: dict[UUID, ExperimentDefinitionWorker], +) -> dict[str, list[RunExecutionAttemptDto]]: + by_task: dict[str, list[RunExecutionAttemptDto]] = defaultdict(list) + for ex in sorted( + executions, + key=lambda e: ("" if e.node_id is None else str(e.node_id), e.attempt_number), + ): + if ex.node_id is None: + continue + tid = str(ex.node_id) + error_msg: str | None = None + if ex.error_json: + message = ex.error_json.get("message") + error_msg = message if isinstance(message, str) else str(ex.error_json) + + worker = worker_map.get(ex.definition_worker_id) if ex.definition_worker_id else None + agent_id = str(worker.id) if worker else None + agent_name = worker.binding_key if worker else None + + resource_ids: list[str] = [] + output = ex.parsed_output() + if "resource_ids" in output: + resource_ids = [str(r) for r in output["resource_ids"]] + + by_task[tid].append( + RunExecutionAttemptDto( + id=str(ex.id), + task_id=tid, + attempt_number=ex.attempt_number, + status=ex.status, + started_at=ex.started_at, + completed_at=ex.completed_at, + final_assistant_message=ex.final_assistant_message, + error_message=error_msg, + score=None, + agent_id=agent_id, + agent_name=agent_name, + output_resource_ids=resource_ids, + ) + ) + return dict(by_task) + + +def _task_keyed_resources( + resources: list[RunResource], + execution_task_map: dict[UUID, UUID], +) -> dict[str, list[RunResourceDto]]: + by_task: dict[str, list[RunResourceDto]] = defaultdict(list) + for resource in resources: + task_id_uuid = ( + execution_task_map.get(resource.task_execution_id) + if resource.task_execution_id + else None + ) + if task_id_uuid is None: + continue + tid = str(task_id_uuid) + by_task[tid].append( + RunResourceDto( + id=str(resource.id), + task_id=tid, + task_execution_id=( + str(resource.task_execution_id) if resource.task_execution_id else "" + ), + name=resource.name, + mime_type=resource.mime_type, + file_path=resource.file_path, + size_bytes=resource.size_bytes, + created_at=resource.created_at, + ) + ) + return dict(by_task) + + +def _task_keyed_evaluations( + evaluations: list[RunTaskEvaluation], + run_id: str, + defn_to_node: dict[UUID, UUID], +) -> dict[str, RunTaskEvaluationDto]: + del defn_to_node + result: dict[str, RunTaskEvaluationDto] = {} + for ev in evaluations: + node_id = ev.node_id + if node_id is None: + # Evaluation rows without runtime node identity cannot be + # truthfully rendered in a task workspace. + continue + tid = str(node_id) + summary = ev.parsed_summary() + + criterion_results = [ + RunEvaluationCriterionDto( + id=f"{ev.id}-{i}", + stage_num=cr.stage_num, + stage_name=cr.stage_name, + criterion_num=cr.criterion_num, + criterion_slug=cr.criterion_slug, + criterion_type=cr.criterion_type, + criterion_description=cr.criterion_description, + criterion_name=cr.criterion_name, + status=cr.status, + passed=cr.passed, + weight=cr.weight, + contribution=cr.contribution, + evaluation_input=cr.evaluation_input, + score=cr.score, + max_score=cr.max_score, + feedback=cr.feedback, + model_reasoning=cr.model_reasoning, + skipped_reason=cr.skipped_reason, + evaluated_action_ids=cr.evaluated_action_ids, + evaluated_resource_ids=cr.evaluated_resource_ids, + observation=cr.observation.model_dump(mode="json") if cr.observation else None, + error=cr.error, + ) + for i, cr in enumerate(summary.criterion_results) + ] + + result[tid] = RunTaskEvaluationDto( + id=str(ev.id), + run_id=run_id, + task_id=tid, + evaluator_name=summary.evaluator_name, + aggregation_rule="weighted_sum", + total_score=0.0 if ev.score is None else ev.score, + max_score=summary.max_score, + normalized_score=summary.normalized_score, + stages_evaluated=summary.stages_evaluated, + stages_passed=summary.stages_passed, + failed_gate=summary.failed_gate, + created_at=ev.created_at, + criterion_results=criterion_results, + ) + return result + + +def _task_keyed_sandboxes( + run_summary: dict, +) -> dict[str, RunSandboxDto]: + """Extract sandbox info from run summary_json if available.""" + result: dict[str, RunSandboxDto] = {} + sandboxes = run_summary.get("sandboxes", {}) + for task_id, sandbox in sandboxes.items(): + commands = [ + RunSandboxCommandDto( + command=cmd.get("command", ""), + stdout=cmd.get("stdout"), + stderr=cmd.get("stderr"), + exit_code=cmd.get("exit_code"), + duration_ms=cmd.get("duration_ms"), + timestamp=cmd.get("timestamp", "1970-01-01T00:00:00Z"), + ) + for cmd in sandbox.get("commands", []) + ] + result[task_id] = RunSandboxDto( + sandbox_id=sandbox.get("sandbox_id", ""), + task_id=task_id, + template=sandbox.get("template"), + timeout_minutes=sandbox.get("timeout_minutes", 5), + status=sandbox.get("status", "unknown"), + created_at=sandbox.get("created_at", "1970-01-01T00:00:00Z"), + closed_at=sandbox.get("closed_at"), + close_reason=sandbox.get("close_reason"), + commands=commands, + ) + return result + + +def _build_communication_threads( + threads: list[Thread], + messages: list[ThreadMessage], + execution_task_map: dict[UUID, UUID], +) -> list[RunCommunicationThreadDto]: + msgs_by_thread: dict[UUID, list[ThreadMessage]] = defaultdict(list) + for message in sorted(messages, key=lambda m: m.sequence_num): + msgs_by_thread[message.thread_id].append(message) + + result: list[RunCommunicationThreadDto] = [] + for thread in threads: + thread_messages = msgs_by_thread.get(thread.id, []) + task_ids = { + task_id + for message in thread_messages + if message.task_execution_id is not None + for task_id in [execution_task_map.get(message.task_execution_id)] + if task_id is not None + } + thread_task_id = next(iter(task_ids)) if len(task_ids) == 1 else None + result.append( + RunCommunicationThreadDto( + id=str(thread.id), + run_id=str(thread.run_id), + task_id=str(thread_task_id) if thread_task_id else None, + topic=thread.topic, + summary=thread.summary, + agent_a_id=thread.agent_a_id, + agent_b_id=thread.agent_b_id, + created_at=thread.created_at, + updated_at=thread.updated_at, + messages=[ + RunCommunicationMessageDto( + id=str(message.id), + thread_id=str(message.thread_id), + run_id=str(message.run_id), + thread_topic=thread.topic, + task_id=( + str(execution_task_map[message.task_execution_id]) + if ( + message.task_execution_id + and message.task_execution_id in execution_task_map + ) + else None + ), + task_execution_id=( + str(message.task_execution_id) + if message.task_execution_id + else None + ), + from_agent_id=message.from_agent_id, + to_agent_id=message.to_agent_id, + content=message.content, + sequence_num=message.sequence_num, + created_at=message.created_at, + ) + for message in thread_messages + ], + ) + ) + return result + + +def _task_timestamps( + executions: list[RunTaskExecution], +) -> dict[UUID, tuple[datetime | None, datetime | None]]: + """Derive per-task started_at/completed_at from execution records.""" + result: dict[UUID, tuple[datetime | None, datetime | None]] = {} + by_task: dict[UUID, list[RunTaskExecution]] = defaultdict(list) + for execution in executions: + if execution.node_id is not None: + by_task[execution.node_id].append(execution) + + for task_id, execs in by_task.items(): + started = min( + (execution.started_at for execution in execs if execution.started_at), + default=None, + ) + completed = max( + (execution.completed_at for execution in execs if execution.completed_at), + default=None, + ) + result[task_id] = (started, completed) + return result + + +def _context_events_by_task( + context_events_rows: list[RunContextEvent], + execution_task_map: dict[UUID, UUID], +) -> dict[str, list[RunContextEventDto]]: + context_events_by_task: dict[str, list[RunContextEventDto]] = defaultdict(list) + for event in context_events_rows: + task_node_id = execution_task_map.get(event.task_execution_id) + if task_node_id is None: + continue + context_events_by_task[str(task_node_id)].append( + RunContextEventDto( + id=event.id, + run_id=event.run_id, + task_execution_id=event.task_execution_id, + task_node_id=task_node_id, + worker_binding_key=event.worker_binding_key, + sequence=event.sequence, + event_type=event.event_type, + payload=event.parsed_payload(), + created_at=event.created_at, + started_at=event.started_at, + completed_at=event.completed_at, + ) + ) + return dict(context_events_by_task) diff --git a/ergon_core/ergon_core/core/runtime/services/run_read_service.py b/ergon_core/ergon_core/core/application/read_models/runs.py similarity index 89% rename from ergon_core/ergon_core/core/runtime/services/run_read_service.py rename to ergon_core/ergon_core/core/application/read_models/runs.py index d1f5ac4a..922efdcd 100644 --- a/ergon_core/ergon_core/core/runtime/services/run_read_service.py +++ b/ergon_core/ergon_core/core/application/read_models/runs.py @@ -6,7 +6,7 @@ from statistics import mean from uuid import UUID -from ergon_core.core.api.schemas import ( +from ergon_core.core.application.read_models.models import ( RunSnapshotDto, TrainingCurvePointDto, TrainingMetricDto, @@ -30,12 +30,22 @@ TrainingMetric, TrainingSession, ) -from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto +from ergon_core.core.application.graph.models import GraphMutationRecordDto +from ergon_core.core.application.evaluation.scoring import aggregate_evaluation_scores +from ergon_core.core.application.read_models.run_snapshot import ( + _build_communication_threads, + _build_task_map, + _context_events_by_task, + _task_keyed_evaluations, + _task_keyed_executions, + _task_keyed_resources, + _task_keyed_sandboxes, + _task_timestamps, +) +from ergon_core.core.application.read_models.resources import require_viewable_resource_size from pydantic import BaseModel from sqlmodel import select -_RESOURCE_CONTENT_MAX_BYTES: int = 10 * 1024 * 1024 - class RunResourceBlob(BaseModel): model_config = {"frozen": True} @@ -49,9 +59,6 @@ class RunReadService: """Owns database reads and DTO shaping for run API endpoints.""" def build_run_snapshot(self, run_id: UUID) -> RunSnapshotDto | None: - # reason: reuse pure DTO helper functions without moving them in the same slice. - from ergon_core.core.api import runs as run_api_helpers - with get_session() as session: run = session.get(RunRecord, run_id) if run is None: @@ -104,7 +111,7 @@ def build_run_snapshot(self, run_id: UUID) -> RunSnapshotDto | None: worker_by_binding: dict[str, ExperimentDefinitionWorker] = { w.binding_key: w for w in def_workers } - timestamps = run_api_helpers._task_timestamps(executions) + timestamps = _task_timestamps(executions) ( task_map, root_task_id, @@ -114,7 +121,7 @@ def build_run_snapshot(self, run_id: UUID) -> RunSnapshotDto | None: failed_tasks, running_tasks, cancelled_tasks, - ) = run_api_helpers._build_task_map(nodes, edges, worker_by_binding, timestamps) + ) = _build_task_map(nodes, edges, worker_by_binding, timestamps) execution_task_map: dict[UUID, UUID] = { ex.id: ex.node_id for ex in executions if ex.node_id is not None @@ -123,16 +130,12 @@ def build_run_snapshot(self, run_id: UUID) -> RunSnapshotDto | None: n.definition_task_id: n.id for n in nodes if n.definition_task_id is not None } - context_events_by_task = run_api_helpers._context_events_by_task( + context_events_by_task = _context_events_by_task( context_events, execution_task_map, ) - final_score: float | None = None - if evaluations: - scores = [ev.score for ev in evaluations if ev.score is not None] - if scores: - final_score = sum(scores) / len(scores) + score_summary = aggregate_evaluation_scores(evaluations) duration_seconds: float | None = None if run.started_at and run.completed_at: @@ -150,22 +153,22 @@ def build_run_snapshot(self, run_id: UUID) -> RunSnapshotDto | None: status=run.status, tasks=task_map, root_task_id=root_task_id, - resources_by_task=run_api_helpers._task_keyed_resources( + resources_by_task=_task_keyed_resources( resources, execution_task_map, ), - executions_by_task=run_api_helpers._task_keyed_executions( + executions_by_task=_task_keyed_executions( executions, worker_by_id, ), - evaluations_by_task=run_api_helpers._task_keyed_evaluations( + evaluations_by_task=_task_keyed_evaluations( evaluations, run_id_str, defn_to_node, ), context_events_by_task=dict(context_events_by_task), - sandboxes_by_task=run_api_helpers._task_keyed_sandboxes(run_summary), - threads=run_api_helpers._build_communication_threads( + sandboxes_by_task=_task_keyed_sandboxes(run_summary), + threads=_build_communication_threads( threads, thread_messages, execution_task_map, @@ -179,7 +182,7 @@ def build_run_snapshot(self, run_id: UUID) -> RunSnapshotDto | None: failed_tasks=failed_tasks, running_tasks=running_tasks, cancelled_tasks=cancelled_tasks, - final_score=final_score, + final_score=score_summary.final_score, error=run.error_message, ) @@ -228,8 +231,7 @@ def get_resource_blob(self, run_id: UUID, resource_id: UUID) -> RunResourceBlob blob_path = Path(resource.file_path).resolve(strict=True) blob_path.relative_to(_blob_root()) size = blob_path.stat().st_size - if size > _RESOURCE_CONTENT_MAX_BYTES: - raise ValueError(f"resource-too-large:{size}") + require_viewable_resource_size(size) return RunResourceBlob( path=blob_path, media_type=resource.mime_type or "application/octet-stream", diff --git a/ergon_core/ergon_core/core/runtime/services/run_service.py b/ergon_core/ergon_core/core/runtime/services/run_service.py deleted file mode 100644 index c8a00876..00000000 --- a/ergon_core/ergon_core/core/runtime/services/run_service.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Run creation, dispatch, and cancellation via Inngest.""" - -import logging -from uuid import UUID - -import inngest -from ergon_core.api.handles import PersistedExperimentDefinition -from ergon_core.core.json_types import JsonObject -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.shared.enums import TERMINAL_RUN_STATUSES, RunStatus -from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.events.infrastructure_events import ( - RunCancelledEvent, - RunCleanupEvent, -) -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.settings import settings -from ergon_core.core.utils import utcnow - -logger = logging.getLogger(__name__) - - -def _checkpoint_metadata() -> JsonObject: - """Checkpoint context for ``RunRecord.summary_json`` (eval watcher / checkpoint subprocess). - - Values come from ``Settings`` (``.env`` + process env), including ``ERGON_CHECKPOINT_*`` - set by the eval runner when spawning evaluation. - """ - if settings.checkpoint_step is None: - return {} - return { - "checkpoint_step": settings.checkpoint_step, - "checkpoint_path": settings.checkpoint_path, - } - - -def create_run( # slopcop: ignore[max-function-params] -- service boundary mirrors RunRecord provenance fields - definition: PersistedExperimentDefinition, - *, - experiment_id: UUID, - workflow_definition_id: UUID, - instance_key: str, - worker_team_json: JsonObject, - evaluator_slug: str | None = None, - model_target: str | None = None, - assignment_json: JsonObject | None = None, - seed: int | None = None, -) -> RunRecord: - with get_session() as session: - run = RunRecord( - experiment_id=experiment_id, - workflow_definition_id=workflow_definition_id, - benchmark_type=definition.benchmark_type, - instance_key=instance_key, - worker_team_json=worker_team_json, - evaluator_slug=evaluator_slug, - model_target=model_target, - assignment_json=assignment_json or {}, - seed=seed, - status=RunStatus.PENDING, - created_at=utcnow(), - summary_json=_checkpoint_metadata(), - ) - session.add(run) - session.commit() - session.refresh(run) - return run - - -def cancel_run(run_id: UUID) -> RunRecord: - """Cancel a run: mark CANCELLED in PG, kill Inngest functions, trigger cleanup.""" - with get_session() as session: - run = session.get(RunRecord, run_id) - if run is None: - raise ValueError(f"Run {run_id} not found") - if run.status in TERMINAL_RUN_STATUSES: - raise ValueError(f"Run {run_id} is already in terminal state: {run.status}") - - run.status = RunStatus.CANCELLED - run.completed_at = utcnow() - session.add(run) - session.commit() - session.refresh(run) - - inngest_client.send_sync( - inngest.Event( - name=RunCancelledEvent.name, - data=RunCancelledEvent(run_id=run_id).model_dump(mode="json"), - ) - ) - - inngest_client.send_sync( - inngest.Event( - name=RunCleanupEvent.name, - data=RunCleanupEvent( - run_id=run_id, - status="cancelled", - ).model_dump(mode="json"), - ) - ) - - logger.info("Cancelled run %s and dispatched cleanup", run_id) - return run From 297a8836af8a1fed64efa64595cab3a5596bfd34 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:45 +0100 Subject: [PATCH 56/66] refactor: move Inngest integration into infrastructure package Made-with: Cursor --- .../{runtime => infrastructure}/__init__.py | 0 .../dependencies.py | 0 .../core/infrastructure/inngest/__init__.py | 0 .../core/infrastructure/inngest/client.py | 34 +++++++ .../core/infrastructure/inngest/contracts.py | 39 ++++++++ .../inngest/errors.py} | 43 ++------- .../inngest/handlers/__init__.py | 0 .../handlers/cancel_orphan_subtasks.py | 36 +++++++ .../inngest/handlers/check_evaluators.py | 26 +++++ .../handlers/cleanup_cancelled_task.py | 21 ++++ .../inngest/handlers/complete_workflow.py | 22 +++++ .../inngest/handlers/evaluate_task_run.py | 21 ++++ .../inngest/handlers/execute_task.py | 32 +++++++ .../inngest/handlers/fail_workflow.py | 22 +++++ .../inngest/handlers/persist_outputs.py | 20 ++++ .../inngest/handlers/propagate_execution.py | 36 +++++++ .../inngest/handlers/run_cleanup.py | 21 ++++ .../inngest/handlers/sandbox_setup.py | 20 ++++ .../inngest/handlers/start_workflow.py | 22 +++++ .../inngest/handlers/worker_execute.py | 20 ++++ .../core/infrastructure/inngest/registry.py | 42 ++++++++ .../core/runtime/errors/__init__.py | 41 -------- .../core/runtime/errors/delegation_errors.py | 95 ------------------- .../core/runtime/errors/graph_errors.py | 57 ----------- .../ergon_core/core/runtime/resources.py | 82 ---------------- 25 files changed, 442 insertions(+), 310 deletions(-) rename ergon_core/ergon_core/core/{runtime => infrastructure}/__init__.py (100%) rename ergon_core/ergon_core/core/{runtime => infrastructure}/dependencies.py (100%) create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/__init__.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/client.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/contracts.py rename ergon_core/ergon_core/core/{runtime/errors/inngest_errors.py => infrastructure/inngest/errors.py} (50%) create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/__init__.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/cancel_orphan_subtasks.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/check_evaluators.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/cleanup_cancelled_task.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/complete_workflow.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/evaluate_task_run.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/execute_task.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/fail_workflow.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/persist_outputs.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/propagate_execution.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/run_cleanup.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/sandbox_setup.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/start_workflow.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/handlers/worker_execute.py create mode 100644 ergon_core/ergon_core/core/infrastructure/inngest/registry.py delete mode 100644 ergon_core/ergon_core/core/runtime/errors/__init__.py delete mode 100644 ergon_core/ergon_core/core/runtime/errors/delegation_errors.py delete mode 100644 ergon_core/ergon_core/core/runtime/errors/graph_errors.py delete mode 100644 ergon_core/ergon_core/core/runtime/resources.py diff --git a/ergon_core/ergon_core/core/runtime/__init__.py b/ergon_core/ergon_core/core/infrastructure/__init__.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/__init__.py rename to ergon_core/ergon_core/core/infrastructure/__init__.py diff --git a/ergon_core/ergon_core/core/runtime/dependencies.py b/ergon_core/ergon_core/core/infrastructure/dependencies.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/dependencies.py rename to ergon_core/ergon_core/core/infrastructure/dependencies.py diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/__init__.py b/ergon_core/ergon_core/core/infrastructure/inngest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/client.py b/ergon_core/ergon_core/core/infrastructure/inngest/client.py new file mode 100644 index 00000000..f7a59636 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/client.py @@ -0,0 +1,34 @@ +"""Inngest client singleton and shared configuration.""" + +import inngest +from ergon_core.core.shared.settings import settings + +InngestEvent = inngest.Event + +inngest_client = inngest.Inngest( + app_id="ergon-core", + event_key=settings.inngest_event_key or "local-dev", + is_production=not settings.inngest_dev, + api_base_url=settings.inngest_api_base_url, + event_api_base_url=settings.inngest_api_base_url, + serializer=inngest.PydanticSerializer(), +) + +# All orchestration functions carry run_id in their trigger event data. +# Sending a run/cancelled event with a matching run_id kills them in-flight. +RUN_CANCEL = [ + inngest.Cancel( + event="run/cancelled", + if_exp="event.data.run_id == async.data.run_id", + ) +] + +# Per-node cancel matcher. Fires on task/cancelled for this exact node_id. +# Used by execute_task_fn to drop queued or terminate in-flight invocations +# when a parent terminates or the manager explicitly cancels. +TASK_CANCEL = [ + inngest.Cancel( + event="task/cancelled", + if_exp="event.data.node_id == async.data.node_id", + ), +] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/contracts.py b/ergon_core/ergon_core/core/infrastructure/inngest/contracts.py new file mode 100644 index 00000000..94c1290e --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/contracts.py @@ -0,0 +1,39 @@ +"""Inngest-facing aliases for application job contracts.""" + +from ergon_core.core.application.jobs.models import ( + EvaluateTaskRunRequest, + EvaluateTaskRunResult, + EvaluatorsResult, + PersistOutputsRequest, + PersistOutputsResult, + RunCleanupResult, + SandboxReadyResult, + SandboxSetupRequest, + TaskExecuteResult, + TaskPropagateResult, + WorkerExecuteRequest, + WorkerExecuteResult, + WorkflowCompleteResult, + WorkflowFailedResult, + WorkflowStartResult, +) +from ergon_core.core.infrastructure.inngest.client import InngestEvent + +__all__ = [ + "EvaluateTaskRunRequest", + "EvaluateTaskRunResult", + "EvaluatorsResult", + "InngestEvent", + "PersistOutputsRequest", + "PersistOutputsResult", + "RunCleanupResult", + "SandboxReadyResult", + "SandboxSetupRequest", + "TaskExecuteResult", + "TaskPropagateResult", + "WorkerExecuteRequest", + "WorkerExecuteResult", + "WorkflowCompleteResult", + "WorkflowFailedResult", + "WorkflowStartResult", +] diff --git a/ergon_core/ergon_core/core/runtime/errors/inngest_errors.py b/ergon_core/ergon_core/core/infrastructure/inngest/errors.py similarity index 50% rename from ergon_core/ergon_core/core/runtime/errors/inngest_errors.py rename to ergon_core/ergon_core/core/infrastructure/inngest/errors.py index 90bdc4c2..8fb1a854 100644 --- a/ergon_core/ergon_core/core/runtime/errors/inngest_errors.py +++ b/ergon_core/ergon_core/core/infrastructure/inngest/errors.py @@ -1,22 +1,13 @@ -"""Non-retryable Inngest errors for the Ergon runtime. - -Each subclass represents a distinct failure category. All auto-log at -ERROR level on construction so the failure is always visible in stdout, -even if the caller swallows or re-wraps the exception. - -Usage: - raise RegistryLookupError("worker", "react-v1") - raise DataIntegrityError("RunRecord", run_id) - raise ConfigurationError("worker_type is not set for task", task_id=task_id) - raise ContractViolationError("sandbox-setup returned dict, expected SandboxReadyResult") -""" +"""Non-retryable Inngest errors for the Ergon runtime.""" import logging from uuid import UUID import inngest -logger = logging.getLogger("ergon.runtime.errors") +logger = logging.getLogger("ergon.infrastructure.inngest") + +NonRetriableError = inngest.NonRetriableError class ErgonNonRetriableError(inngest.NonRetriableError): @@ -30,12 +21,7 @@ def __init__(self, message: str, **context: object) -> None: class RegistryLookupError(ErgonNonRetriableError): - """A slug was not found in the builtins registry. - - This is a definition-level problem: the experiment references a - benchmark/worker/evaluator/sandbox-manager that is not registered. - Retrying will always produce the same miss. - """ + """A slug was not found in the builtins registry.""" def __init__(self, registry_name: str, slug: str, **context: object) -> None: super().__init__( @@ -45,12 +31,7 @@ def __init__(self, registry_name: str, slug: str, **context: object) -> None: class DataIntegrityError(ErgonNonRetriableError): - """A required DB row is missing or corrupt. - - The row should have been created by a prior step in the pipeline. - Its absence indicates a data integrity violation that will not - self-heal on retry. - """ + """A required DB row is missing or corrupt.""" def __init__(self, entity: str, entity_id: UUID | str, **context: object) -> None: super().__init__( @@ -60,22 +41,14 @@ def __init__(self, entity: str, entity_id: UUID | str, **context: object) -> Non class ConfigurationError(ErgonNonRetriableError): - """An experiment definition has invalid or missing configuration. - - Examples: worker_type not set on a task assignment, unknown status - string in an event payload. - """ + """An experiment definition has invalid or missing configuration.""" def __init__(self, detail: str, **context: object) -> None: super().__init__(detail, **context) class ContractViolationError(ErgonNonRetriableError): - """A runtime contract or invariant was broken. - - Examples: an Inngest step returned an unexpected type, a spec/result - index mismatch, or an unreachable code path was reached. - """ + """A runtime contract or invariant was broken.""" def __init__(self, detail: str, **context: object) -> None: super().__init__(detail, **context) diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/__init__.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/cancel_orphan_subtasks.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/cancel_orphan_subtasks.py new file mode 100644 index 00000000..11262c0c --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/cancel_orphan_subtasks.py @@ -0,0 +1,36 @@ +"""Inngest adapters for descendant cancellation.""" + +import inngest + +from ergon_core.core.application.jobs.cancel_orphan_subtasks import ( + run_block_descendants_on_failed_job, + run_cancel_orphans_on_cancelled_job, +) +from ergon_core.core.infrastructure.inngest.client import RUN_CANCEL, inngest_client +from ergon_core.core.application.events.task_events import TaskCancelledEvent, TaskFailedEvent + + +@inngest_client.create_function( + fn_id="block-descendants-on-failed", + trigger=inngest.TriggerEvent(event="task/failed"), + cancel=RUN_CANCEL, + retries=1, +) +async def block_descendants_on_failed_fn(ctx: inngest.Context) -> int: + return await run_block_descendants_on_failed_job(ctx, TaskFailedEvent.model_validate(ctx.event.data)) + + +@inngest_client.create_function( + fn_id="cancel-orphans-on-cancelled", + trigger=inngest.TriggerEvent(event="task/cancelled"), + cancel=RUN_CANCEL, + retries=1, +) +async def cancel_orphans_on_cancelled_fn(ctx: inngest.Context) -> int: + return await run_cancel_orphans_on_cancelled_job( + ctx, + TaskCancelledEvent.model_validate(ctx.event.data), + ) + + +__all__ = ["block_descendants_on_failed_fn", "cancel_orphans_on_cancelled_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/check_evaluators.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/check_evaluators.py new file mode 100644 index 00000000..52d47c86 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/check_evaluators.py @@ -0,0 +1,26 @@ +"""Inngest adapter for evaluator dispatch.""" + +import inngest + +from ergon_core.core.application.jobs.check_evaluators import run_check_evaluators_job +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.infrastructure.inngest.contracts import EvaluatorsResult +from ergon_core.core.infrastructure.inngest.handlers.evaluate_task_run import evaluate_task_run +from ergon_core.core.application.events.task_events import TaskCompletedEvent + + +@inngest_client.create_function( + fn_id="task-check-evaluators", + trigger=inngest.TriggerEvent(event=TaskCompletedEvent.name), + retries=1, + output_type=EvaluatorsResult, +) +async def check_and_run_evaluators(ctx: inngest.Context) -> EvaluatorsResult: + return await run_check_evaluators_job( + ctx, + TaskCompletedEvent.model_validate(ctx.event.data), + evaluate_task_run_function=evaluate_task_run, + ) + + +__all__ = ["check_and_run_evaluators"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/cleanup_cancelled_task.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/cleanup_cancelled_task.py new file mode 100644 index 00000000..4deb0e32 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/cleanup_cancelled_task.py @@ -0,0 +1,21 @@ +"""Inngest adapter for cancelled task cleanup.""" + +import inngest + +from ergon_core.core.application.jobs.cleanup_cancelled_task import run_cleanup_cancelled_task_job +from ergon_core.core.infrastructure.inngest.client import RUN_CANCEL, inngest_client +from ergon_core.core.application.events.task_events import TaskCancelledEvent +from ergon_core.core.shared.json_types import JsonObject + + +@inngest_client.create_function( + fn_id="cleanup-cancelled-task", + trigger=inngest.TriggerEvent(event="task/cancelled"), + cancel=RUN_CANCEL, + retries=3, +) +async def cleanup_cancelled_task_fn(ctx: inngest.Context) -> JsonObject: + return await run_cleanup_cancelled_task_job(ctx, TaskCancelledEvent.model_validate(ctx.event.data)) + + +__all__ = ["cleanup_cancelled_task_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/complete_workflow.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/complete_workflow.py new file mode 100644 index 00000000..3c06c18a --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/complete_workflow.py @@ -0,0 +1,22 @@ +"""Inngest adapter for workflow completion finalization.""" + +import inngest + +from ergon_core.core.application.jobs.complete_workflow import run_complete_workflow_job +from ergon_core.core.infrastructure.inngest.client import RUN_CANCEL, inngest_client +from ergon_core.core.infrastructure.inngest.contracts import WorkflowCompleteResult +from ergon_core.core.application.events.task_events import WorkflowCompletedEvent + + +@inngest_client.create_function( + fn_id="workflow-complete", + trigger=inngest.TriggerEvent(event="workflow/completed"), + cancel=RUN_CANCEL, + retries=1, + output_type=WorkflowCompleteResult, +) +async def complete_workflow_fn(ctx: inngest.Context) -> WorkflowCompleteResult: + return await run_complete_workflow_job(WorkflowCompletedEvent.model_validate(ctx.event.data)) + + +__all__ = ["complete_workflow_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/evaluate_task_run.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/evaluate_task_run.py new file mode 100644 index 00000000..5c03fe94 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/evaluate_task_run.py @@ -0,0 +1,21 @@ +"""Inngest adapter for task evaluation.""" + +import inngest + +from ergon_core.core.application.jobs.evaluate_task_run import run_evaluate_task_run_job +from ergon_core.core.infrastructure.inngest.client import RUN_CANCEL, inngest_client +from ergon_core.core.infrastructure.inngest.contracts import EvaluateTaskRunRequest, EvaluateTaskRunResult + + +@inngest_client.create_function( + fn_id="evaluate-task-run", + trigger=inngest.TriggerEvent(event="task/evaluate"), + cancel=RUN_CANCEL, + retries=1, + output_type=EvaluateTaskRunResult, +) +async def evaluate_task_run(ctx: inngest.Context) -> EvaluateTaskRunResult: + return await run_evaluate_task_run_job(ctx, EvaluateTaskRunRequest.model_validate(ctx.event.data)) + + +__all__ = ["evaluate_task_run"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/execute_task.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/execute_task.py new file mode 100644 index 00000000..165090e5 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/execute_task.py @@ -0,0 +1,32 @@ +"""Inngest adapter for task execution orchestration.""" + +import inngest + +from ergon_core.core.application.jobs.execute_task import run_execute_task_job +from ergon_core.core.infrastructure.inngest.client import RUN_CANCEL, TASK_CANCEL, inngest_client +from ergon_core.core.infrastructure.inngest.contracts import TaskExecuteResult +from ergon_core.core.infrastructure.inngest.handlers.persist_outputs import persist_outputs_fn +from ergon_core.core.infrastructure.inngest.handlers.sandbox_setup import sandbox_setup_fn +from ergon_core.core.infrastructure.inngest.handlers.worker_execute import worker_execute_fn +from ergon_core.core.application.events.task_events import TaskReadyEvent + + +@inngest_client.create_function( + fn_id="task-execute", + trigger=inngest.TriggerEvent(event="task/ready"), + cancel=[*RUN_CANCEL, *TASK_CANCEL], + retries=0, + concurrency=[inngest.Concurrency(limit=15)], + output_type=TaskExecuteResult, +) +async def execute_task_fn(ctx: inngest.Context) -> TaskExecuteResult: + return await run_execute_task_job( + ctx, + TaskReadyEvent.model_validate(ctx.event.data), + sandbox_setup_function=sandbox_setup_fn, + worker_execute_function=worker_execute_fn, + persist_outputs_function=persist_outputs_fn, + ) + + +__all__ = ["execute_task_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/fail_workflow.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/fail_workflow.py new file mode 100644 index 00000000..e8d437f1 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/fail_workflow.py @@ -0,0 +1,22 @@ +"""Inngest adapter for workflow failure handling.""" + +import inngest + +from ergon_core.core.application.jobs.fail_workflow import run_fail_workflow_job +from ergon_core.core.infrastructure.inngest.client import RUN_CANCEL, inngest_client +from ergon_core.core.infrastructure.inngest.contracts import WorkflowFailedResult +from ergon_core.core.application.events.task_events import WorkflowFailedEvent + + +@inngest_client.create_function( + fn_id="workflow-failed", + trigger=inngest.TriggerEvent(event="workflow/failed"), + cancel=RUN_CANCEL, + retries=1, + output_type=WorkflowFailedResult, +) +async def fail_workflow_fn(ctx: inngest.Context) -> WorkflowFailedResult: + return await run_fail_workflow_job(WorkflowFailedEvent.model_validate(ctx.event.data)) + + +__all__ = ["fail_workflow_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/persist_outputs.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/persist_outputs.py new file mode 100644 index 00000000..3b38ed30 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/persist_outputs.py @@ -0,0 +1,20 @@ +"""Inngest adapter for sandbox output persistence.""" + +import inngest + +from ergon_core.core.application.jobs.persist_outputs import run_persist_outputs_job +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.infrastructure.inngest.contracts import PersistOutputsRequest, PersistOutputsResult + + +@inngest_client.create_function( + fn_id="persist-outputs", + trigger=inngest.TriggerEvent(event="task/persist-outputs"), + retries=1, + output_type=PersistOutputsResult, +) +async def persist_outputs_fn(ctx: inngest.Context) -> PersistOutputsResult: + return await run_persist_outputs_job(PersistOutputsRequest.model_validate(ctx.event.data)) + + +__all__ = ["persist_outputs_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/propagate_execution.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/propagate_execution.py new file mode 100644 index 00000000..5b258dca --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/propagate_execution.py @@ -0,0 +1,36 @@ +"""Inngest adapters for task propagation.""" + +import inngest + +from ergon_core.core.application.jobs.propagate_execution import ( + run_propagate_task_failure_job, + run_propagate_task_job, +) +from ergon_core.core.infrastructure.inngest.client import RUN_CANCEL, inngest_client +from ergon_core.core.infrastructure.inngest.contracts import TaskPropagateResult +from ergon_core.core.application.events.task_events import TaskCompletedEvent, TaskFailedEvent + + +@inngest_client.create_function( + fn_id="task-propagate", + trigger=inngest.TriggerEvent(event="task/completed"), + cancel=RUN_CANCEL, + retries=1, + output_type=TaskPropagateResult, +) +async def propagate_task_fn(ctx: inngest.Context) -> TaskPropagateResult: + return await run_propagate_task_job(TaskCompletedEvent.model_validate(ctx.event.data)) + + +@inngest_client.create_function( + fn_id="task-failure-propagate", + trigger=inngest.TriggerEvent(event="task/failed"), + cancel=RUN_CANCEL, + retries=1, + output_type=TaskPropagateResult, +) +async def propagate_task_failure_fn(ctx: inngest.Context) -> TaskPropagateResult: + return await run_propagate_task_failure_job(TaskFailedEvent.model_validate(ctx.event.data)) + + +__all__ = ["propagate_task_failure_fn", "propagate_task_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/run_cleanup.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/run_cleanup.py new file mode 100644 index 00000000..6773273d --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/run_cleanup.py @@ -0,0 +1,21 @@ +"""Inngest adapter for run cleanup.""" + +import inngest + +from ergon_core.core.application.jobs.run_cleanup import run_run_cleanup_job +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.infrastructure.inngest.contracts import RunCleanupResult +from ergon_core.core.application.events.infrastructure_events import RunCleanupEvent + + +@inngest_client.create_function( + fn_id="run-cleanup", + trigger=inngest.TriggerEvent(event="run/cleanup"), + retries=0, + output_type=RunCleanupResult, +) +async def run_cleanup_fn(ctx: inngest.Context) -> RunCleanupResult: + return await run_run_cleanup_job(ctx, RunCleanupEvent.model_validate(ctx.event.data)) + + +__all__ = ["run_cleanup_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/sandbox_setup.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/sandbox_setup.py new file mode 100644 index 00000000..41831144 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/sandbox_setup.py @@ -0,0 +1,20 @@ +"""Inngest adapter for sandbox setup.""" + +import inngest + +from ergon_core.core.application.jobs.sandbox_setup import run_sandbox_setup_job +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.infrastructure.inngest.contracts import SandboxReadyResult, SandboxSetupRequest + + +@inngest_client.create_function( + fn_id="sandbox-setup", + trigger=inngest.TriggerEvent(event="task/sandbox-setup"), + retries=1, + output_type=SandboxReadyResult, +) +async def sandbox_setup_fn(ctx: inngest.Context) -> SandboxReadyResult: + return await run_sandbox_setup_job(ctx, SandboxSetupRequest.model_validate(ctx.event.data)) + + +__all__ = ["sandbox_setup_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/start_workflow.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/start_workflow.py new file mode 100644 index 00000000..e2971fe3 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/start_workflow.py @@ -0,0 +1,22 @@ +"""Inngest adapter for workflow initialization.""" + +import inngest + +from ergon_core.core.application.jobs.start_workflow import run_start_workflow_job +from ergon_core.core.infrastructure.inngest.client import RUN_CANCEL, inngest_client +from ergon_core.core.infrastructure.inngest.contracts import WorkflowStartResult +from ergon_core.core.application.events.task_events import WorkflowStartedEvent + + +@inngest_client.create_function( + fn_id="workflow-start", + trigger=inngest.TriggerEvent(event="workflow/started"), + cancel=RUN_CANCEL, + retries=1, + output_type=WorkflowStartResult, +) +async def start_workflow_fn(ctx: inngest.Context) -> WorkflowStartResult: + return await run_start_workflow_job(WorkflowStartedEvent.model_validate(ctx.event.data)) + + +__all__ = ["start_workflow_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/handlers/worker_execute.py b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/worker_execute.py new file mode 100644 index 00000000..e6fcc7e7 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/handlers/worker_execute.py @@ -0,0 +1,20 @@ +"""Inngest adapter for worker execution.""" + +import inngest + +from ergon_core.core.application.jobs.worker_execute import run_worker_execute_job +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.infrastructure.inngest.contracts import WorkerExecuteRequest, WorkerExecuteResult + + +@inngest_client.create_function( + fn_id="worker-execute", + trigger=inngest.TriggerEvent(event="task/worker-execute"), + retries=0, + output_type=WorkerExecuteResult, +) +async def worker_execute_fn(ctx: inngest.Context) -> WorkerExecuteResult: + return await run_worker_execute_job(WorkerExecuteRequest.model_validate(ctx.event.data)) + + +__all__ = ["worker_execute_fn"] diff --git a/ergon_core/ergon_core/core/infrastructure/inngest/registry.py b/ergon_core/ergon_core/core/infrastructure/inngest/registry.py new file mode 100644 index 00000000..582c0740 --- /dev/null +++ b/ergon_core/ergon_core/core/infrastructure/inngest/registry.py @@ -0,0 +1,42 @@ +"""Central registry of all Inngest functions for the ergon-core app. + +Pass ALL_FUNCTIONS to inngest.serve() or the framework integration. +""" + +from ergon_core.core.infrastructure.inngest.handlers.cancel_orphan_subtasks import ( + block_descendants_on_failed_fn, + cancel_orphans_on_cancelled_fn, +) +from ergon_core.core.infrastructure.inngest.handlers.check_evaluators import check_and_run_evaluators +from ergon_core.core.infrastructure.inngest.handlers.cleanup_cancelled_task import cleanup_cancelled_task_fn +from ergon_core.core.infrastructure.inngest.handlers.complete_workflow import complete_workflow_fn +from ergon_core.core.infrastructure.inngest.handlers.evaluate_task_run import evaluate_task_run +from ergon_core.core.infrastructure.inngest.handlers.execute_task import execute_task_fn +from ergon_core.core.infrastructure.inngest.handlers.fail_workflow import fail_workflow_fn +from ergon_core.core.infrastructure.inngest.handlers.persist_outputs import persist_outputs_fn +from ergon_core.core.infrastructure.inngest.handlers.propagate_execution import ( + propagate_task_failure_fn, + propagate_task_fn, +) +from ergon_core.core.infrastructure.inngest.handlers.run_cleanup import run_cleanup_fn +from ergon_core.core.infrastructure.inngest.handlers.sandbox_setup import sandbox_setup_fn +from ergon_core.core.infrastructure.inngest.handlers.start_workflow import start_workflow_fn +from ergon_core.core.infrastructure.inngest.handlers.worker_execute import worker_execute_fn + +ALL_FUNCTIONS = [ + start_workflow_fn, + execute_task_fn, + propagate_task_fn, + propagate_task_failure_fn, + complete_workflow_fn, + fail_workflow_fn, + sandbox_setup_fn, + worker_execute_fn, + persist_outputs_fn, + check_and_run_evaluators, + evaluate_task_run, + block_descendants_on_failed_fn, + cancel_orphans_on_cancelled_fn, + cleanup_cancelled_task_fn, + run_cleanup_fn, +] diff --git a/ergon_core/ergon_core/core/runtime/errors/__init__.py b/ergon_core/ergon_core/core/runtime/errors/__init__.py deleted file mode 100644 index 1b3312ba..00000000 --- a/ergon_core/ergon_core/core/runtime/errors/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Custom errors for Ergon runtime. - -Inngest errors auto-log at ERROR level on construction so failures -are always visible in stdout regardless of how the caller handles them. - -Graph errors are runtime-agnostic (no Inngest dependency). -""" - -from ergon_core.core.runtime.errors.delegation_errors import ( - DelegationError, - TaskAlreadyTerminalError, -) -from ergon_core.core.runtime.errors.graph_errors import ( - CycleError, - DanglingEdgeError, - EdgeNotFoundError, - GraphError, - NodeNotFoundError, -) -from ergon_core.core.runtime.errors.inngest_errors import ( - ConfigurationError, - ContractViolationError, - DataIntegrityError, - ErgonNonRetriableError, - RegistryLookupError, -) - -__all__ = [ - "DelegationError", - "ErgonNonRetriableError", - "ConfigurationError", - "ContractViolationError", - "CycleError", - "DanglingEdgeError", - "DataIntegrityError", - "EdgeNotFoundError", - "GraphError", - "NodeNotFoundError", - "RegistryLookupError", - "TaskAlreadyTerminalError", -] diff --git a/ergon_core/ergon_core/core/runtime/errors/delegation_errors.py b/ergon_core/ergon_core/core/runtime/errors/delegation_errors.py deleted file mode 100644 index cb91b666..00000000 --- a/ergon_core/ergon_core/core/runtime/errors/delegation_errors.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Errors raised by TaskManagementService delegation tools.""" - -from uuid import UUID - -from ergon_core.core.runtime.errors.graph_errors import GraphError - - -class DelegationError(GraphError): - """Base for delegation-specific errors.""" - - pass - - -class TaskRunningError(DelegationError): - """refine_task called on a node that is currently RUNNING. - - The worker is actively consuming the description; editing it mid-flight - would produce inconsistent behaviour. The caller should cancel or wait - for the task to terminate, then refine + restart. - """ - - def __init__(self, node_id: UUID, current_status: str) -> None: - super().__init__( - f"Cannot refine node {node_id}: status is '{current_status}' " - "(refine is blocked while a worker is running)" - ) - self.node_id = node_id - self.current_status = current_status - - -class TaskNotTerminalError(DelegationError): - """restart_task called on a node that is not in a terminal status. - - Only COMPLETED, FAILED, or CANCELLED nodes can be restarted. A PENDING - node hasn't run yet; a RUNNING node is live — the manager should cancel - first if it wants to restart. - """ - - def __init__(self, node_id: UUID, current_status: str) -> None: - super().__init__( - f"Cannot restart node {node_id}: status is '{current_status}', " - "expected one of 'completed', 'failed', 'cancelled'" - ) - self.node_id = node_id - self.current_status = current_status - - -class TaskAlreadyTerminalError(DelegationError): - """cancel_task called on an already-terminal node.""" - - def __init__(self, node_id: UUID, current_status: str) -> None: - super().__init__(f"Cannot cancel node {node_id}: already terminal ('{current_status}')") - self.node_id = node_id - self.current_status = current_status - - -class CycleDetectedError(DelegationError): - """Raised when plan_subtasks dependency graph contains a cycle.""" - - def __init__(self, remaining_slugs: list[str]) -> None: - super().__init__(f"Cycle detected among task_slugs: {remaining_slugs}") - self.remaining_slugs = remaining_slugs - - -class DuplicateTaskSlugError(DelegationError): - """Raised when plan_subtasks has duplicate task_slug values.""" - - def __init__(self, task_slug: str) -> None: - super().__init__(f"Duplicate task_slug: {task_slug!r}") - self.task_slug = task_slug - - -class UnknownTaskSlugError(DelegationError): - """Raised when depends_on references a task_slug not in the plan.""" - - def __init__(self, slugs: list[str]) -> None: - super().__init__(f"Unknown depends_on task_slugs: {slugs}") - self.slugs = slugs - - -class RunRecordMissingError(DelegationError): - """Raised when a service is asked to mutate a run that has no RunRecord. - - Every run must have a RunRecord (with ``experiment_definition_id``) - before any task/graph service is invoked on it. This is enforced as a - hard invariant so that missing fixtures in tests surface as a loud - failure instead of silently resolving to a sentinel definition id. - """ - - def __init__(self, run_id: UUID) -> None: - super().__init__( - f"RunRecord missing for run_id={run_id}; seed a RunRecord before " - "invoking TaskManagementService.", - ) - self.run_id = run_id diff --git a/ergon_core/ergon_core/core/runtime/errors/graph_errors.py b/ergon_core/ergon_core/core/runtime/errors/graph_errors.py deleted file mode 100644 index e836e44c..00000000 --- a/ergon_core/ergon_core/core/runtime/errors/graph_errors.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Graph repository errors. - -Deliberately NOT Inngest-specific (no NonRetriableError subclass). -The graph layer must stay independent of the execution runtime so it -can be reused in training pipelines, replay systems, and test harnesses -that don't run inside Inngest. The Inngest layer wraps these into -NonRetriableError at the function boundary if needed. -""" - -import logging -from uuid import UUID - -logger = logging.getLogger("ergon.graph") - - -class GraphError(Exception): - """Base for all graph repository errors.""" - - def __init__(self, message: str, **context: object) -> None: - ctx_str = " ".join(f"{k}={v}" for k, v in context.items()) if context else "" - full = f"{message} {ctx_str}".strip() - logger.error("[%s] %s", type(self).__name__, full) - super().__init__(full) - - -class CycleError(GraphError): - """Adding the proposed edge would create a cycle.""" - - def __init__(self, source_id: UUID, target_id: UUID, **context: object) -> None: - super().__init__( - f"Edge {source_id} -> {target_id} would create a cycle", - **context, - ) - - -class NodeNotFoundError(GraphError): - """Referenced node does not exist in this run's graph.""" - - def __init__(self, node_id: UUID, **context: object) -> None: - super().__init__(f"Node {node_id} not found", **context) - - -class EdgeNotFoundError(GraphError): - """Referenced edge does not exist in this run's graph.""" - - def __init__(self, edge_id: UUID, **context: object) -> None: - super().__init__(f"Edge {edge_id} not found", **context) - - -class DanglingEdgeError(GraphError): - """Edge references a node that does not exist.""" - - def __init__(self, edge_id: UUID, missing_node_id: UUID, **context: object) -> None: - super().__init__( - f"Edge {edge_id} references missing node {missing_node_id}", - **context, - ) diff --git a/ergon_core/ergon_core/core/runtime/resources.py b/ergon_core/ergon_core/core/runtime/resources.py deleted file mode 100644 index bbb2f795..00000000 --- a/ergon_core/ergon_core/core/runtime/resources.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Runtime resource DTOs and resource-log enums.""" - -from datetime import datetime -from typing import TYPE_CHECKING -from uuid import UUID - -from ergon_core.core.json_types import JsonObject -from ergon_core.core.persistence.shared.enums import RunResourceKind -from pydantic import BaseModel, ConfigDict, Field - -if TYPE_CHECKING: - from ergon_core.core.persistence.telemetry.models import RunResource as _RunResourceRow - -__all__ = ["RunResourceKind", "RunResourceView"] - - -class RunResourceView(BaseModel): - """Read-only DTO for a ``run_resources`` row. - - Construct via ``RunResourceView.from_row(orm_row)``. - """ - - model_config = ConfigDict(frozen=True) - - id: UUID = Field(description="Primary key of the run_resources row.") - run_id: UUID = Field(description="The run this resource was produced in.") - task_execution_id: UUID | None = Field( - description=( - "The task execution that produced the resource, or ``None`` for " - "run-scoped resources (e.g. aggregate reports)." - ), - ) - kind: RunResourceKind = Field( - description="Canonical category (report, worker_output, trace, etc.).", - ) - name: str = Field( - description="Human-readable name -- usually the sandbox file name or the output slot.", - ) - mime_type: str = Field( - description="Best-effort MIME type, guessed from ``name`` if not provided.", - ) - file_path: str = Field( - description=( - "Absolute path to the content-addressed blob on disk " - "(``${ERGON_BLOB_ROOT}//``)." - ), - ) - size_bytes: int = Field(description="Size of the blob in bytes.") - content_hash: str | None = Field( - description="SHA-256 hex digest of the blob; used for dedup and verification.", - ) - error: str | None = Field( - description="Populated only when writing the resource failed; ``None`` on success.", - ) - metadata: JsonObject = Field( - default_factory=dict, - description='Free-form publisher metadata (e.g. ``{"sandbox_origin": "..."}``).', - ) - created_at: datetime = Field( - description=( - "Row insertion time; the log is append-only, so ``(created_at, id)`` " - "DESC defines 'latest' for a given file_path." - ), - ) - - @classmethod - def from_row(cls, row: "_RunResourceRow") -> "RunResourceView": - """Map an ORM ``RunResource`` row to a frozen DTO.""" - return cls( - id=row.id, - run_id=row.run_id, - task_execution_id=row.task_execution_id, - kind=RunResourceKind(row.kind), - name=row.name, - mime_type=row.mime_type, - file_path=row.file_path, - size_bytes=row.size_bytes, - content_hash=row.content_hash, - error=row.error, - metadata=row.metadata_json, - created_at=row.created_at, - ) From 984a946818e0d3fee0a42544027ce0e70c4d4985 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:45 +0100 Subject: [PATCH 57/66] refactor: move sandbox infrastructure package Made-with: Cursor --- .../{ => infrastructure}/sandbox/__init__.py | 2 +- .../{ => infrastructure}/sandbox/errors.py | 0 .../sandbox/event_sink.py | 0 .../sandbox/instrumentation.py | 4 +- .../{ => infrastructure}/sandbox/lifecycle.py | 2 +- .../{ => infrastructure}/sandbox/manager.py | 10 +-- .../sandbox/resource_publisher.py | 80 +++++++++++-------- .../{ => infrastructure}/sandbox/utils.py | 0 .../test_support/sandbox/stub_manager.py | 2 +- 9 files changed, 56 insertions(+), 44 deletions(-) rename ergon_core/ergon_core/core/{ => infrastructure}/sandbox/__init__.py (71%) rename ergon_core/ergon_core/core/{ => infrastructure}/sandbox/errors.py (100%) rename ergon_core/ergon_core/core/{ => infrastructure}/sandbox/event_sink.py (100%) rename ergon_core/ergon_core/core/{ => infrastructure}/sandbox/instrumentation.py (98%) rename ergon_core/ergon_core/core/{ => infrastructure}/sandbox/lifecycle.py (95%) rename ergon_core/ergon_core/core/{ => infrastructure}/sandbox/manager.py (98%) rename ergon_core/ergon_core/core/{ => infrastructure}/sandbox/resource_publisher.py (74%) rename ergon_core/ergon_core/core/{ => infrastructure}/sandbox/utils.py (100%) diff --git a/ergon_core/ergon_core/core/sandbox/__init__.py b/ergon_core/ergon_core/core/infrastructure/sandbox/__init__.py similarity index 71% rename from ergon_core/ergon_core/core/sandbox/__init__.py rename to ergon_core/ergon_core/core/infrastructure/sandbox/__init__.py index 288b875c..fcd5d87d 100644 --- a/ergon_core/ergon_core/core/sandbox/__init__.py +++ b/ergon_core/ergon_core/core/infrastructure/sandbox/__init__.py @@ -1,7 +1,7 @@ """Sandbox management: provisioning, file I/O, lifecycle. Import concrete modules directly, for example -``ergon_core.core.sandbox.manager``. Keeping this package initializer +``ergon_core.core.infrastructure.sandbox.manager``. Keeping this package initializer lightweight avoids import cycles between telemetry models and API DTO modules. """ diff --git a/ergon_core/ergon_core/core/sandbox/errors.py b/ergon_core/ergon_core/core/infrastructure/sandbox/errors.py similarity index 100% rename from ergon_core/ergon_core/core/sandbox/errors.py rename to ergon_core/ergon_core/core/infrastructure/sandbox/errors.py diff --git a/ergon_core/ergon_core/core/sandbox/event_sink.py b/ergon_core/ergon_core/core/infrastructure/sandbox/event_sink.py similarity index 100% rename from ergon_core/ergon_core/core/sandbox/event_sink.py rename to ergon_core/ergon_core/core/infrastructure/sandbox/event_sink.py diff --git a/ergon_core/ergon_core/core/sandbox/instrumentation.py b/ergon_core/ergon_core/core/infrastructure/sandbox/instrumentation.py similarity index 98% rename from ergon_core/ergon_core/core/sandbox/instrumentation.py rename to ergon_core/ergon_core/core/infrastructure/sandbox/instrumentation.py index 55824fdb..665c4e5d 100644 --- a/ergon_core/ergon_core/core/sandbox/instrumentation.py +++ b/ergon_core/ergon_core/core/infrastructure/sandbox/instrumentation.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, Protocol from uuid import UUID -from ergon_core.core.sandbox.event_sink import SandboxEventSink -from ergon_core.core.sandbox.utils import ( +from ergon_core.core.infrastructure.sandbox.event_sink import SandboxEventSink +from ergon_core.core.infrastructure.sandbox.utils import ( _truncate, bytes_length, coerce_text, diff --git a/ergon_core/ergon_core/core/sandbox/lifecycle.py b/ergon_core/ergon_core/core/infrastructure/sandbox/lifecycle.py similarity index 95% rename from ergon_core/ergon_core/core/sandbox/lifecycle.py rename to ergon_core/ergon_core/core/infrastructure/sandbox/lifecycle.py index c6a862c5..e0eda9c8 100644 --- a/ergon_core/ergon_core/core/sandbox/lifecycle.py +++ b/ergon_core/ergon_core/core/infrastructure/sandbox/lifecycle.py @@ -32,7 +32,7 @@ async def terminate_sandbox_by_id(sandbox_id: str | None) -> SandboxTerminationR try: # reason: avoid import cycle between sandbox manager/event sink and telemetry models. - from ergon_core.core.sandbox.manager import ( + from ergon_core.core.infrastructure.sandbox.manager import ( BaseSandboxManager, ) diff --git a/ergon_core/ergon_core/core/sandbox/manager.py b/ergon_core/ergon_core/core/infrastructure/sandbox/manager.py similarity index 98% rename from ergon_core/ergon_core/core/sandbox/manager.py rename to ergon_core/ergon_core/core/infrastructure/sandbox/manager.py index abd15641..a14af813 100644 --- a/ergon_core/ergon_core/core/sandbox/manager.py +++ b/ergon_core/ergon_core/core/infrastructure/sandbox/manager.py @@ -8,13 +8,13 @@ from typing import ClassVar, Protocol, runtime_checkable from uuid import UUID -from ergon_core.core.sandbox.errors import SandboxExpiredError -from ergon_core.core.sandbox.event_sink import ( +from ergon_core.core.infrastructure.sandbox.errors import SandboxExpiredError +from ergon_core.core.infrastructure.sandbox.event_sink import ( NoopSandboxEventSink, SandboxEventSink, ) -from ergon_core.core.sandbox.utils import _truncate, coerce_text -from ergon_core.core.settings import settings +from ergon_core.core.infrastructure.sandbox.utils import _truncate, coerce_text +from ergon_core.core.shared.settings import settings from pydantic import BaseModel @@ -114,7 +114,7 @@ def set_event_sink(cls, sink: SandboxEventSink) -> None: Production callers MUST NOT call this after startup. The only sanctioned call site is inside the ``lifespan`` context manager in - ``ergon_core/ergon_core/core/api/app.py``. + ``ergon_core/ergon_core/core/rest_api/app.py``. """ cls._event_sink = sink diff --git a/ergon_core/ergon_core/core/sandbox/resource_publisher.py b/ergon_core/ergon_core/core/infrastructure/sandbox/resource_publisher.py similarity index 74% rename from ergon_core/ergon_core/core/sandbox/resource_publisher.py rename to ergon_core/ergon_core/core/infrastructure/sandbox/resource_publisher.py index ea38fac7..6c82a0c9 100644 --- a/ergon_core/ergon_core/core/sandbox/resource_publisher.py +++ b/ergon_core/ergon_core/core/infrastructure/sandbox/resource_publisher.py @@ -2,7 +2,6 @@ Copies bytes out of an E2B sandbox into a content-addressed blob store on the local filesystem, then appends one row per new hash to ``run_resources``. -All persistence goes through ``queries.resources`` (no session parameter). """ import hashlib @@ -14,9 +13,9 @@ from uuid import UUID from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] -from ergon_core.core.persistence.queries import queries +from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import RunResourceKind -from ergon_core.core.runtime.resources import RunResourceView +from ergon_core.core.application.resources import RunResourceRepository, RunResourceView logger = logging.getLogger(__name__) @@ -54,6 +53,7 @@ def __init__( self._task_execution_id = task_execution_id self._blob_root = blob_root self._publish_dirs = publish_dirs if publish_dirs is not None else self.DEFAULT_PUBLISH_DIRS + self._resource_repo = RunResourceRepository() # ------------------------------------------------------------------ # Filesystem sync -- called from write-type toolkit methods and from @@ -83,10 +83,12 @@ async def sync(self) -> list[RunResourceView]: # path. Any existing row with this file_path in the current task # execution is proof the content is already logged. durable_path = self._blob_path(content_hash) - prior = queries.resources.latest_by_path( - task_execution_id=self._task_execution_id, - file_path=str(durable_path), - ) + with get_session() as session: + prior = self._resource_repo.latest_by_path( + session, + task_execution_id=self._task_execution_id, + file_path=str(durable_path), + ) if prior is not None: continue # unchanged @@ -96,18 +98,22 @@ async def sync(self) -> list[RunResourceView]: guessed, _ = mimetypes.guess_type(entry.name) mime = guessed or "application/octet-stream" - row = queries.resources.append( - run_id=self._run_id, - task_execution_id=self._task_execution_id, - kind=resource_kind.value, - name=entry.name, - mime_type=mime, - file_path=str(durable_path), - size_bytes=len(content_bytes), - error=None, - content_hash=content_hash, - metadata={"sandbox_origin": sandbox_full_path}, - ) + with get_session() as session: + row = self._resource_repo.append( + session, + run_id=self._run_id, + task_execution_id=self._task_execution_id, + kind=resource_kind.value, + name=entry.name, + mime_type=mime, + file_path=str(durable_path), + size_bytes=len(content_bytes), + error=None, + content_hash=content_hash, + metadata={"sandbox_origin": sandbox_full_path}, + ) + session.commit() + session.refresh(row) created.append(RunResourceView.from_row(row)) return created @@ -132,26 +138,32 @@ def publish_value( content_bytes = content.encode("utf-8") content_hash = hashlib.sha256(content_bytes).hexdigest() - prior = queries.resources.find_by_hash( - task_execution_id=self._task_execution_id, - content_hash=content_hash, - ) + with get_session() as session: + prior = self._resource_repo.find_by_hash( + session, + task_execution_id=self._task_execution_id, + content_hash=content_hash, + ) if prior is not None: return None # duplicate, no-op durable_path = self._write_blob(content_bytes, content_hash) - row = queries.resources.append( - run_id=self._run_id, - task_execution_id=self._task_execution_id, - kind=kind.value, - name=name, - mime_type=mime_type, - file_path=str(durable_path), - size_bytes=len(content_bytes), - error=None, - content_hash=content_hash, - ) + with get_session() as session: + row = self._resource_repo.append( + session, + run_id=self._run_id, + task_execution_id=self._task_execution_id, + kind=kind.value, + name=name, + mime_type=mime_type, + file_path=str(durable_path), + size_bytes=len(content_bytes), + error=None, + content_hash=content_hash, + ) + session.commit() + session.refresh(row) return RunResourceView.from_row(row) # ------------------------------------------------------------------ diff --git a/ergon_core/ergon_core/core/sandbox/utils.py b/ergon_core/ergon_core/core/infrastructure/sandbox/utils.py similarity index 100% rename from ergon_core/ergon_core/core/sandbox/utils.py rename to ergon_core/ergon_core/core/infrastructure/sandbox/utils.py diff --git a/ergon_core/ergon_core/test_support/sandbox/stub_manager.py b/ergon_core/ergon_core/test_support/sandbox/stub_manager.py index eed4676e..a73f5628 100644 --- a/ergon_core/ergon_core/test_support/sandbox/stub_manager.py +++ b/ergon_core/ergon_core/test_support/sandbox/stub_manager.py @@ -4,7 +4,7 @@ from typing import cast from uuid import UUID -from ergon_core.core.sandbox.manager import AsyncSandbox, BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.manager import AsyncSandbox, BaseSandboxManager from ergon_core.test_support.sandbox.sentinel import STUB_SANDBOX_PREFIX logger = logging.getLogger(__name__) From 5a2fd4b3a4a23019bad33805ddaf6907566a3861 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:46 +0100 Subject: [PATCH 58/66] refactor: move tracing and dashboard infrastructure Made-with: Cursor --- .../dashboard/__init__.py | 6 ++-- .../{ => infrastructure}/dashboard/emitter.py | 30 +++++++++---------- .../dashboard/event_contracts.py | 10 ++++--- .../dashboard/provider.py | 2 +- .../tracing/__init__.py | 16 +++++----- .../tracing/attributes.py | 4 +-- .../tracing/contexts.py | 4 +-- .../tracing/ids.py | 0 .../tracing/noop.py | 6 ++-- .../tracing/otel.py | 10 +++---- .../tracing/sinks.py | 8 ++--- .../tracing/types.py | 2 +- 12 files changed, 49 insertions(+), 49 deletions(-) rename ergon_core/ergon_core/core/{ => infrastructure}/dashboard/__init__.py (85%) rename ergon_core/ergon_core/core/{ => infrastructure}/dashboard/emitter.py (94%) rename ergon_core/ergon_core/core/{ => infrastructure}/dashboard/event_contracts.py (95%) rename ergon_core/ergon_core/core/{ => infrastructure}/dashboard/provider.py (93%) rename ergon_core/ergon_core/core/{runtime => infrastructure}/tracing/__init__.py (78%) rename ergon_core/ergon_core/core/{runtime => infrastructure}/tracing/attributes.py (91%) rename ergon_core/ergon_core/core/{runtime => infrastructure}/tracing/contexts.py (96%) rename ergon_core/ergon_core/core/{runtime => infrastructure}/tracing/ids.py (100%) rename ergon_core/ergon_core/core/{runtime => infrastructure}/tracing/noop.py (86%) rename ergon_core/ergon_core/core/{runtime => infrastructure}/tracing/otel.py (92%) rename ergon_core/ergon_core/core/{runtime => infrastructure}/tracing/sinks.py (72%) rename ergon_core/ergon_core/core/{runtime => infrastructure}/tracing/types.py (96%) diff --git a/ergon_core/ergon_core/core/dashboard/__init__.py b/ergon_core/ergon_core/core/infrastructure/dashboard/__init__.py similarity index 85% rename from ergon_core/ergon_core/core/dashboard/__init__.py rename to ergon_core/ergon_core/core/infrastructure/dashboard/__init__.py index 33db25e9..038dbf05 100644 --- a/ergon_core/ergon_core/core/dashboard/__init__.py +++ b/ergon_core/ergon_core/core/infrastructure/dashboard/__init__.py @@ -1,10 +1,10 @@ """Dashboard emission module — re-exports for convenience.""" -from ergon_core.core.dashboard.emitter import ( +from ergon_core.core.infrastructure.dashboard.emitter import ( DashboardEmitter, emit_cohort_updated_for_run, ) -from ergon_core.core.dashboard.event_contracts import ( +from ergon_core.core.infrastructure.dashboard.event_contracts import ( CohortUpdatedEvent, DashboardResourcePublishedEvent, DashboardSandboxClosedEvent, @@ -17,7 +17,7 @@ DashboardWorkflowStartedEvent, TaskTreeNode, ) -from ergon_core.core.dashboard.provider import ( +from ergon_core.core.infrastructure.dashboard.provider import ( get_dashboard_emitter, init_dashboard_emitter, reset_dashboard_emitter, diff --git a/ergon_core/ergon_core/core/dashboard/emitter.py b/ergon_core/ergon_core/core/infrastructure/dashboard/emitter.py similarity index 94% rename from ergon_core/ergon_core/core/dashboard/emitter.py rename to ergon_core/ergon_core/core/infrastructure/dashboard/emitter.py index 40456c3d..49096ccc 100644 --- a/ergon_core/ergon_core/core/dashboard/emitter.py +++ b/ergon_core/ergon_core/core/infrastructure/dashboard/emitter.py @@ -9,9 +9,11 @@ from uuid import UUID import inngest -from ergon_core.core.api.schemas import ( +from ergon_core.core.application.communication.models import ( RunCommunicationMessageDto, RunCommunicationThreadDto, +) +from ergon_core.core.application.read_models.models import ( RunTaskEvaluationDto, ) from ergon_core.core.persistence.context.event_payloads import ContextEventType @@ -22,21 +24,17 @@ ) from ergon_core.core.persistence.shared.types import RunId from ergon_core.core.persistence.graph.status_conventions import NodeStatus -from ergon_core.core.persistence.queries import queries -from ergon_core.core.runtime.events.task_events import TaskCancelledEvent -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.cohort_schemas import CohortSummaryDto -from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service -from ergon_core.core.runtime.services.cohort_stats_service import ( - experiment_cohort_stats_service, -) -from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto, GraphMutationValue -from ergon_core.core.utils import utcnow +from ergon_core.core.application.events.task_events import TaskCancelledEvent +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.application.read_models.models import CohortSummaryDto +from ergon_core.core.application.read_models.cohorts import experiment_cohort_service +from ergon_core.core.application.graph.models import GraphMutationRecordDto, GraphMutationValue +from ergon_core.core.shared.utils import utcnow if TYPE_CHECKING: from ergon_core.core.persistence.context.models import RunContextEvent -from ergon_core.core.dashboard.event_contracts import ( +from ergon_core.core.infrastructure.dashboard.event_contracts import ( CohortUpdatedEvent, DashboardContextEventEvent, DashboardGraphMutationEvent, @@ -396,7 +394,7 @@ def register_execution(self, execution_id: UUID, task_node_id: UUID) -> None: self._execution_task_map[execution_id] = task_node_id async def on_context_event(self, event: "RunContextEvent") -> None: - """Called by ContextEventRepository after each event is committed.""" + """Called by ContextEventService after each event is committed.""" if not self._enabled: return try: @@ -451,15 +449,15 @@ async def cohort_updated( async def emit_cohort_updated_for_run(run_id: UUID) -> None: """Refresh and emit the current cohort summary for a run, if it has a cohort.""" - cohort_id = queries.runs.get_cohort_id(run_id) + cohort_id = experiment_cohort_service.cohort_id_for_run(run_id) if cohort_id is None: return - experiment_cohort_stats_service.recompute(cohort_id) + experiment_cohort_service.recompute(cohort_id) summary = experiment_cohort_service.get_summary(cohort_id) if summary is None: return - from ergon_core.core.dashboard.provider import get_dashboard_emitter + from ergon_core.core.infrastructure.dashboard.provider import get_dashboard_emitter await get_dashboard_emitter().cohort_updated( cohort_id=summary.cohort_id, diff --git a/ergon_core/ergon_core/core/dashboard/event_contracts.py b/ergon_core/ergon_core/core/infrastructure/dashboard/event_contracts.py similarity index 95% rename from ergon_core/ergon_core/core/dashboard/event_contracts.py rename to ergon_core/ergon_core/core/infrastructure/dashboard/event_contracts.py index cadcd658..5b91efbc 100644 --- a/ergon_core/ergon_core/core/dashboard/event_contracts.py +++ b/ergon_core/ergon_core/core/infrastructure/dashboard/event_contracts.py @@ -11,9 +11,11 @@ from typing import ClassVar from uuid import UUID -from ergon_core.core.api.schemas import ( +from ergon_core.core.application.communication.models import ( RunCommunicationMessageDto, RunCommunicationThreadDto, +) +from ergon_core.core.application.read_models.models import ( RunTaskEvaluationDto, ) from ergon_core.core.persistence.context.event_payloads import ( @@ -21,9 +23,9 @@ ContextEventType, ) from ergon_core.core.persistence.graph.status_conventions import NodeStatus -from ergon_core.core.runtime.events.base import InngestEventContract -from ergon_core.core.runtime.services.cohort_schemas import CohortSummaryDto -from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto +from ergon_core.core.application.events.base import InngestEventContract +from ergon_core.core.application.read_models.models import CohortSummaryDto +from ergon_core.core.application.graph.models import GraphMutationRecordDto from pydantic import BaseModel, Field # --------------------------------------------------------------------------- diff --git a/ergon_core/ergon_core/core/dashboard/provider.py b/ergon_core/ergon_core/core/infrastructure/dashboard/provider.py similarity index 93% rename from ergon_core/ergon_core/core/dashboard/provider.py rename to ergon_core/ergon_core/core/infrastructure/dashboard/provider.py index eadc1090..971ca127 100644 --- a/ergon_core/ergon_core/core/dashboard/provider.py +++ b/ergon_core/ergon_core/core/infrastructure/dashboard/provider.py @@ -4,7 +4,7 @@ request can retrieve the initialized process instance from here. """ -from ergon_core.core.dashboard.emitter import DashboardEmitter +from ergon_core.core.infrastructure.dashboard.emitter import DashboardEmitter _dashboard_emitter: DashboardEmitter | None = None diff --git a/ergon_core/ergon_core/core/runtime/tracing/__init__.py b/ergon_core/ergon_core/core/infrastructure/tracing/__init__.py similarity index 78% rename from ergon_core/ergon_core/core/runtime/tracing/__init__.py rename to ergon_core/ergon_core/core/infrastructure/tracing/__init__.py index 82db1111..5ff90458 100644 --- a/ergon_core/ergon_core/core/runtime/tracing/__init__.py +++ b/ergon_core/ergon_core/core/infrastructure/tracing/__init__.py @@ -3,7 +3,7 @@ The runtime emits structured spans through this package while keeping the existing public import path stable: - from ergon_core.core.runtime.tracing import get_trace_sink + from ergon_core.core.infrastructure.tracing import get_trace_sink Target span hierarchy (one trace per run, keyed by run_id):: @@ -27,13 +27,13 @@ schemas per span. """ -from ergon_core.core.runtime.tracing.attributes import ( +from ergon_core.core.infrastructure.tracing.attributes import ( datetime_to_nanos, normalize_attributes, safe_json_attribute, truncate_text, ) -from ergon_core.core.runtime.tracing.contexts import ( +from ergon_core.core.infrastructure.tracing.contexts import ( evaluation_criterion_context, evaluation_task_context, persist_outputs_context, @@ -46,15 +46,15 @@ workflow_start_context, worker_execute_context, ) -from ergon_core.core.runtime.tracing.ids import ( +from ergon_core.core.infrastructure.tracing.ids import ( DeterministicIdGenerator, span_id_from_key, trace_id_from_run_id, ) -from ergon_core.core.runtime.tracing.noop import NoopTraceSink -from ergon_core.core.runtime.tracing.otel import OtelTraceSink -from ergon_core.core.runtime.tracing.sinks import get_trace_sink -from ergon_core.core.runtime.tracing.types import CompletedSpan, SpanEvent, TraceContext, TraceSink +from ergon_core.core.infrastructure.tracing.noop import NoopTraceSink +from ergon_core.core.infrastructure.tracing.otel import OtelTraceSink +from ergon_core.core.infrastructure.tracing.sinks import get_trace_sink +from ergon_core.core.infrastructure.tracing.types import CompletedSpan, SpanEvent, TraceContext, TraceSink __all__ = [ "CompletedSpan", diff --git a/ergon_core/ergon_core/core/runtime/tracing/attributes.py b/ergon_core/ergon_core/core/infrastructure/tracing/attributes.py similarity index 91% rename from ergon_core/ergon_core/core/runtime/tracing/attributes.py rename to ergon_core/ergon_core/core/infrastructure/tracing/attributes.py index 1775b2cd..2411b0ee 100644 --- a/ergon_core/ergon_core/core/runtime/tracing/attributes.py +++ b/ergon_core/ergon_core/core/infrastructure/tracing/attributes.py @@ -3,8 +3,8 @@ import json from datetime import UTC, datetime -from ergon_core.core.json_types import JsonObject, JsonValue -from ergon_core.core.settings import settings +from ergon_core.core.shared.json_types import JsonObject, JsonValue +from ergon_core.core.shared.settings import settings def truncate_text(value: str | None, max_length: int | None = None) -> str | None: diff --git a/ergon_core/ergon_core/core/runtime/tracing/contexts.py b/ergon_core/ergon_core/core/infrastructure/tracing/contexts.py similarity index 96% rename from ergon_core/ergon_core/core/runtime/tracing/contexts.py rename to ergon_core/ergon_core/core/infrastructure/tracing/contexts.py index baa01720..fed57ef2 100644 --- a/ergon_core/ergon_core/core/runtime/tracing/contexts.py +++ b/ergon_core/ergon_core/core/infrastructure/tracing/contexts.py @@ -7,8 +7,8 @@ from uuid import UUID -from ergon_core.core.runtime.tracing.ids import span_id_from_key, trace_id_from_run_id -from ergon_core.core.runtime.tracing.types import TraceContext +from ergon_core.core.infrastructure.tracing.ids import span_id_from_key, trace_id_from_run_id +from ergon_core.core.infrastructure.tracing.types import TraceContext def workflow_root_context(run_id: UUID) -> TraceContext: diff --git a/ergon_core/ergon_core/core/runtime/tracing/ids.py b/ergon_core/ergon_core/core/infrastructure/tracing/ids.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/tracing/ids.py rename to ergon_core/ergon_core/core/infrastructure/tracing/ids.py diff --git a/ergon_core/ergon_core/core/runtime/tracing/noop.py b/ergon_core/ergon_core/core/infrastructure/tracing/noop.py similarity index 86% rename from ergon_core/ergon_core/core/runtime/tracing/noop.py rename to ergon_core/ergon_core/core/infrastructure/tracing/noop.py index b18809b8..768bb2f8 100644 --- a/ergon_core/ergon_core/core/runtime/tracing/noop.py +++ b/ergon_core/ergon_core/core/infrastructure/tracing/noop.py @@ -3,9 +3,9 @@ from datetime import datetime from uuid import UUID -from ergon_core.core.json_types import JsonObject -from ergon_core.core.runtime.tracing.ids import span_id_from_key -from ergon_core.core.runtime.tracing.types import CompletedSpan, TraceContext +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.infrastructure.tracing.ids import span_id_from_key +from ergon_core.core.infrastructure.tracing.types import CompletedSpan, TraceContext class NoopTraceSink: diff --git a/ergon_core/ergon_core/core/runtime/tracing/otel.py b/ergon_core/ergon_core/core/infrastructure/tracing/otel.py similarity index 92% rename from ergon_core/ergon_core/core/runtime/tracing/otel.py rename to ergon_core/ergon_core/core/infrastructure/tracing/otel.py index 6e6a7921..5c54a937 100644 --- a/ergon_core/ergon_core/core/runtime/tracing/otel.py +++ b/ergon_core/ergon_core/core/infrastructure/tracing/otel.py @@ -25,17 +25,17 @@ except ImportError: _OTLPSpanExporter = None -from ergon_core.core.json_types import JsonObject -from ergon_core.core.runtime.tracing.attributes import datetime_to_nanos, normalize_attributes -from ergon_core.core.runtime.tracing.ids import ( +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.infrastructure.tracing.attributes import datetime_to_nanos, normalize_attributes +from ergon_core.core.infrastructure.tracing.ids import ( EMPTY_SPAN_ID, TRACE_FLAGS_SAMPLED, DeterministicIdGenerator, id_override, span_id_from_key, ) -from ergon_core.core.runtime.tracing.types import CompletedSpan, SpanEvent, TraceContext -from ergon_core.core.settings import settings +from ergon_core.core.infrastructure.tracing.types import CompletedSpan, SpanEvent, TraceContext +from ergon_core.core.shared.settings import settings class OtelTraceSink: diff --git a/ergon_core/ergon_core/core/runtime/tracing/sinks.py b/ergon_core/ergon_core/core/infrastructure/tracing/sinks.py similarity index 72% rename from ergon_core/ergon_core/core/runtime/tracing/sinks.py rename to ergon_core/ergon_core/core/infrastructure/tracing/sinks.py index 34607a4b..63b9eb02 100644 --- a/ergon_core/ergon_core/core/runtime/tracing/sinks.py +++ b/ergon_core/ergon_core/core/infrastructure/tracing/sinks.py @@ -1,9 +1,9 @@ """Process-wide trace sink factory.""" -from ergon_core.core.runtime.tracing.noop import NoopTraceSink -from ergon_core.core.runtime.tracing.otel import OtelTraceSink -from ergon_core.core.runtime.tracing.types import TraceSink -from ergon_core.core.settings import settings +from ergon_core.core.infrastructure.tracing.noop import NoopTraceSink +from ergon_core.core.infrastructure.tracing.otel import OtelTraceSink +from ergon_core.core.infrastructure.tracing.types import TraceSink +from ergon_core.core.shared.settings import settings def _create_sink() -> TraceSink: diff --git a/ergon_core/ergon_core/core/runtime/tracing/types.py b/ergon_core/ergon_core/core/infrastructure/tracing/types.py similarity index 96% rename from ergon_core/ergon_core/core/runtime/tracing/types.py rename to ergon_core/ergon_core/core/infrastructure/tracing/types.py index 2fd6cc6c..05e3e775 100644 --- a/ergon_core/ergon_core/core/runtime/tracing/types.py +++ b/ergon_core/ergon_core/core/infrastructure/tracing/types.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject class TraceContext(BaseModel): From 23cf32fe5d66a17af8c5785a01f74fda5e0ebaf4 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:46 +0100 Subject: [PATCH 59/66] refactor: move FastAPI routes into rest api package Made-with: Cursor --- ergon_core/ergon_core/core/api/runs.py | 525 ------------------ ergon_core/ergon_core/core/api/schemas.py | 249 --------- .../ergon_core/core/rest_api/__init__.py | 1 + .../ergon_core/core/{api => rest_api}/app.py | 34 +- .../core/{api => rest_api}/cohorts.py | 4 +- .../core/{api => rest_api}/experiments.py | 13 +- .../core/{api => rest_api}/rollouts.py | 0 ergon_core/ergon_core/core/rest_api/runs.py | 88 +++ .../core/{api => rest_api}/test_harness.py | 23 +- 9 files changed, 126 insertions(+), 811 deletions(-) delete mode 100644 ergon_core/ergon_core/core/api/runs.py delete mode 100644 ergon_core/ergon_core/core/api/schemas.py create mode 100644 ergon_core/ergon_core/core/rest_api/__init__.py rename ergon_core/ergon_core/core/{api => rest_api}/app.py (74%) rename ergon_core/ergon_core/core/{api => rest_api}/cohorts.py (91%) rename ergon_core/ergon_core/core/{api => rest_api}/experiments.py (74%) rename ergon_core/ergon_core/core/{api => rest_api}/rollouts.py (100%) create mode 100644 ergon_core/ergon_core/core/rest_api/runs.py rename ergon_core/ergon_core/core/{api => rest_api}/test_harness.py (95%) diff --git a/ergon_core/ergon_core/core/api/runs.py b/ergon_core/ergon_core/core/api/runs.py deleted file mode 100644 index 3745207b..00000000 --- a/ergon_core/ergon_core/core/api/runs.py +++ /dev/null @@ -1,525 +0,0 @@ -"""FastAPI router for persisted run-detail snapshots.""" - -from collections import defaultdict -from datetime import datetime -from typing import Any -from uuid import UUID - -from ergon_core.core.api.schemas import ( - RunCommunicationMessageDto, - RunCommunicationThreadDto, - RunContextEventDto, - RunEvaluationCriterionDto, - RunExecutionAttemptDto, - RunResourceDto, - RunSandboxCommandDto, - RunSandboxDto, - RunSnapshotDto, - RunTaskDto, - RunTaskEvaluationDto, - TrainingCurvePointDto, - TrainingMetricDto, - TrainingSessionDto, -) -from ergon_core.core.persistence.context.models import RunContextEvent -from ergon_core.core.persistence.definitions.models import ( - ExperimentDefinition, - ExperimentDefinitionWorker, -) -from ergon_core.core.persistence.graph.models import RunGraphEdge, RunGraphMutation, RunGraphNode -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.telemetry.models import ( - RunRecord, - RunResource, - RunTaskEvaluation, - RunTaskExecution, - Thread, - ThreadMessage, - TrainingMetric, - TrainingSession, -) -from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto -from ergon_core.core.runtime.services.run_read_service import RunReadService -from fastapi import APIRouter, HTTPException -from fastapi.responses import FileResponse -from sqlmodel import Session, select - -router = APIRouter(prefix="/runs", tags=["runs"]) - - -# --------------------------------------------------------------------------- -# Task tree helpers -# --------------------------------------------------------------------------- - - -def _build_task_map( - nodes: list[RunGraphNode], - edges: list[RunGraphEdge], - worker_by_binding: dict[str, ExperimentDefinitionWorker], - task_timestamps: dict[UUID, tuple[datetime | None, datetime | None]], -) -> tuple[dict[str, RunTaskDto], str, int, int, int, int, int, int]: - """Three clean passes using stored containment columns. - - Pass 1: node columns (parent_node_id, level) — no edge traversal. - Pass 2: reverse lookup for child_ids and is_leaf. - Pass 3: dependency edges -> depends_on_ids. - """ - if not nodes: - return {}, "", 0, 0, 0, 0, 0, 0 - - task_map: dict[str, RunTaskDto] = {} - - # Pass 1: build every node DTO from stored columns - for node in nodes: - nid = str(node.id) - worker = ( - worker_by_binding.get(node.assigned_worker_slug) - if node.assigned_worker_slug is not None - else None - ) - started_at, completed_at = task_timestamps.get(node.id, (None, None)) - task_map[nid] = RunTaskDto( - id=nid, - name=node.task_slug, - description=node.description, - status=node.status, - parent_id=str(node.parent_node_id) if node.parent_node_id else None, - child_ids=[], - depends_on_ids=[], - is_leaf=True, - level=node.level, - assigned_worker_id=str(worker.id) if worker else None, - assigned_worker_slug=node.assigned_worker_slug, - started_at=started_at, - completed_at=completed_at, - ) - - # Pass 2: derive child_ids and is_leaf from parent_id - for nid, dto in task_map.items(): - if dto.parent_id and dto.parent_id in task_map: - parent = task_map[dto.parent_id] - task_map[dto.parent_id] = parent.model_copy( - update={"child_ids": [*parent.child_ids, nid], "is_leaf": False} - ) - - # Pass 3: dependency edges -> depends_on_ids - for edge in edges: - src, tgt = str(edge.source_node_id), str(edge.target_node_id) - target_task = task_map.get(tgt) - if target_task is None: - continue - task_map[tgt] = target_task.model_copy( - update={"depends_on_ids": [*target_task.depends_on_ids, src]} - ) - - root_id = next((t.id for t in task_map.values() if t.parent_id is None), "") - total = len(task_map) - leaves = [t for t in task_map.values() if t.is_leaf] - total_leaf = len(leaves) - completed = sum(1 for t in leaves if t.status == "completed") - failed = sum(1 for t in leaves if t.status == "failed") - running = sum(1 for t in leaves if t.status == "running") - cancelled = sum(1 for t in leaves if t.status == "cancelled") - - return task_map, root_id, total, total_leaf, completed, failed, running, cancelled - - -# --------------------------------------------------------------------------- -# Per-task keyed helpers -# --------------------------------------------------------------------------- - - -def _task_keyed_executions( - executions: list[RunTaskExecution], - worker_map: dict[UUID, ExperimentDefinitionWorker], -) -> dict[str, list[RunExecutionAttemptDto]]: - by_task: dict[str, list[RunExecutionAttemptDto]] = defaultdict(list) - for ex in sorted( - executions, - key=lambda e: ("" if e.node_id is None else str(e.node_id), e.attempt_number), - ): - if ex.node_id is None: - continue - tid = str(ex.node_id) - error_msg: str | None = None - if ex.error_json: - message = ex.error_json.get("message") - error_msg = message if isinstance(message, str) else str(ex.error_json) - - worker = worker_map.get(ex.definition_worker_id) if ex.definition_worker_id else None - agent_id = str(worker.id) if worker else None - agent_name = worker.binding_key if worker else None - - resource_ids: list[str] = [] - output = ex.parsed_output() - if "resource_ids" in output: - resource_ids = [str(r) for r in output["resource_ids"]] - - by_task[tid].append( - RunExecutionAttemptDto( - id=str(ex.id), - task_id=tid, - attempt_number=ex.attempt_number, - status=ex.status, - started_at=ex.started_at, - completed_at=ex.completed_at, - final_assistant_message=ex.final_assistant_message, - error_message=error_msg, - score=None, - agent_id=agent_id, - agent_name=agent_name, - output_resource_ids=resource_ids, - ) - ) - return dict(by_task) - - -def _task_keyed_resources( - resources: list[RunResource], - execution_task_map: dict[UUID, UUID], -) -> dict[str, list[RunResourceDto]]: - by_task: dict[str, list[RunResourceDto]] = defaultdict(list) - for r in resources: - task_id_uuid = execution_task_map.get(r.task_execution_id) if r.task_execution_id else None - if task_id_uuid is None: - continue - tid = str(task_id_uuid) - by_task[tid].append( - RunResourceDto( - id=str(r.id), - task_id=tid, - task_execution_id=str(r.task_execution_id) if r.task_execution_id else "", - name=r.name, - mime_type=r.mime_type, - file_path=r.file_path, - size_bytes=r.size_bytes, - created_at=r.created_at, - ) - ) - return dict(by_task) - - -def _task_keyed_evaluations( - evaluations: list[RunTaskEvaluation], - run_id: str, - defn_to_node: dict[UUID, UUID], -) -> dict[str, RunTaskEvaluationDto]: - result: dict[str, RunTaskEvaluationDto] = {} - for ev in evaluations: - node_id = ev.node_id - if node_id is None: - # Evaluation rows without runtime node identity cannot be - # truthfully rendered in a task workspace. - continue - tid = str(node_id) - summary = ev.parsed_summary() - - criterion_results = [ - RunEvaluationCriterionDto( - id=f"{ev.id}-{i}", - stage_num=cr.stage_num, - stage_name=cr.stage_name, - criterion_num=cr.criterion_num, - criterion_slug=cr.criterion_slug, - criterion_type=cr.criterion_type, - criterion_description=cr.criterion_description, - criterion_name=cr.criterion_name, - status=cr.status, - passed=cr.passed, - weight=cr.weight, - contribution=cr.contribution, - evaluation_input=cr.evaluation_input, - score=cr.score, - max_score=cr.max_score, - feedback=cr.feedback, - model_reasoning=cr.model_reasoning, - skipped_reason=cr.skipped_reason, - evaluated_action_ids=cr.evaluated_action_ids, - evaluated_resource_ids=cr.evaluated_resource_ids, - observation=cr.observation.model_dump(mode="json") if cr.observation else None, - error=cr.error, - ) - for i, cr in enumerate(summary.criterion_results) - ] - - result[tid] = RunTaskEvaluationDto( - id=str(ev.id), - run_id=run_id, - task_id=tid, - evaluator_name=summary.evaluator_name, - aggregation_rule="weighted_sum", - total_score=0.0 if ev.score is None else ev.score, - max_score=summary.max_score, - normalized_score=summary.normalized_score, - stages_evaluated=summary.stages_evaluated, - stages_passed=summary.stages_passed, - failed_gate=summary.failed_gate, - created_at=ev.created_at, - criterion_results=criterion_results, - ) - return result - - -def _task_keyed_sandboxes( - run_summary: dict, -) -> dict[str, RunSandboxDto]: - """Extract sandbox info from run summary_json if available.""" - result: dict[str, RunSandboxDto] = {} - sandboxes = run_summary.get("sandboxes", {}) - for task_id, sb in sandboxes.items(): - commands = [ - RunSandboxCommandDto( - command=cmd.get("command", ""), - stdout=cmd.get("stdout"), - stderr=cmd.get("stderr"), - exit_code=cmd.get("exit_code"), - duration_ms=cmd.get("duration_ms"), - timestamp=cmd.get("timestamp", "1970-01-01T00:00:00Z"), - ) - for cmd in sb.get("commands", []) - ] - result[task_id] = RunSandboxDto( - sandbox_id=sb.get("sandbox_id", ""), - task_id=task_id, - template=sb.get("template"), - timeout_minutes=sb.get("timeout_minutes", 5), - status=sb.get("status", "unknown"), - created_at=sb.get("created_at", "1970-01-01T00:00:00Z"), - closed_at=sb.get("closed_at"), - close_reason=sb.get("close_reason"), - commands=commands, - ) - return result - - -# --------------------------------------------------------------------------- -# Current task statuses from state events -# --------------------------------------------------------------------------- - - -def _build_communication_threads( - threads: list[Thread], - messages: list[ThreadMessage], - execution_task_map: dict[UUID, UUID], -) -> list[RunCommunicationThreadDto]: - msgs_by_thread: dict[UUID, list[ThreadMessage]] = defaultdict(list) - for m in sorted(messages, key=lambda m: m.sequence_num): - msgs_by_thread[m.thread_id].append(m) - - result: list[RunCommunicationThreadDto] = [] - for t in threads: - thread_messages = msgs_by_thread.get(t.id, []) - task_ids = { - task_id - for message in thread_messages - if message.task_execution_id is not None - for task_id in [execution_task_map.get(message.task_execution_id)] - if task_id is not None - } - thread_task_id = next(iter(task_ids)) if len(task_ids) == 1 else None - result.append( - RunCommunicationThreadDto( - id=str(t.id), - run_id=str(t.run_id), - task_id=str(thread_task_id) if thread_task_id else None, - topic=t.topic, - summary=t.summary, - agent_a_id=t.agent_a_id, - agent_b_id=t.agent_b_id, - created_at=t.created_at, - updated_at=t.updated_at, - messages=[ - RunCommunicationMessageDto( - id=str(m.id), - thread_id=str(m.thread_id), - run_id=str(m.run_id), - thread_topic=t.topic, - task_id=( - str(execution_task_map[m.task_execution_id]) - if m.task_execution_id and m.task_execution_id in execution_task_map - else None - ), - task_execution_id=str(m.task_execution_id) if m.task_execution_id else None, - from_agent_id=m.from_agent_id, - to_agent_id=m.to_agent_id, - content=m.content, - sequence_num=m.sequence_num, - created_at=m.created_at, - ) - for m in thread_messages - ], - ) - ) - return result - - -def _task_timestamps( - executions: list[RunTaskExecution], -) -> dict[UUID, tuple[datetime | None, datetime | None]]: - """Derive per-task started_at/completed_at from execution records.""" - result: dict[UUID, tuple[datetime | None, datetime | None]] = {} - by_task: dict[UUID, list[RunTaskExecution]] = defaultdict(list) - for ex in executions: - if ex.node_id is not None: - by_task[ex.node_id].append(ex) - - for task_id, execs in by_task.items(): - started = min((e.started_at for e in execs if e.started_at), default=None) - completed = max((e.completed_at for e in execs if e.completed_at), default=None) - result[task_id] = (started, completed) - return result - - -def _context_events_by_task( - context_events_rows: list[RunContextEvent], - execution_task_map: dict[UUID, UUID], -) -> dict[str, list[RunContextEventDto]]: - context_events_by_task: dict[str, list[RunContextEventDto]] = defaultdict(list) - for event in context_events_rows: - task_node_id = execution_task_map.get(event.task_execution_id) - if task_node_id is None: - continue - context_events_by_task[str(task_node_id)].append( - RunContextEventDto( - id=event.id, - run_id=event.run_id, - task_execution_id=event.task_execution_id, - task_node_id=task_node_id, - worker_binding_key=event.worker_binding_key, - sequence=event.sequence, - event_type=event.event_type, - payload=event.parsed_payload(), - created_at=event.created_at, - started_at=event.started_at, - completed_at=event.completed_at, - ) - ) - return dict(context_events_by_task) - - -# --------------------------------------------------------------------------- -# Snapshot builder -# --------------------------------------------------------------------------- - - -def build_run_snapshot(run_id: UUID) -> RunSnapshotDto | None: - return RunReadService().build_run_snapshot(run_id) - - -# --------------------------------------------------------------------------- -# Endpoint -# --------------------------------------------------------------------------- - - -@router.get("/{run_id}", response_model=RunSnapshotDto) -def get_run(run_id: UUID) -> RunSnapshotDto: - """Get a persisted run-detail snapshot suitable for frontend hydration.""" - snapshot = build_run_snapshot(run_id) - if snapshot is None: - raise HTTPException(status_code=404, detail=f"Run {run_id} not found") - return snapshot - - -# --------------------------------------------------------------------------- -# Mutations endpoint (Timeline scrubber) -# --------------------------------------------------------------------------- - - -@router.get("/{run_id}/mutations", response_model=list[GraphMutationRecordDto]) -def get_mutations(run_id: UUID) -> list[GraphMutationRecordDto]: - """Return the append-only mutation log for a run, ordered by sequence. - - Used by the Timeline scrubber to replay DAG state at any point in time. - """ - mutations = RunReadService().list_mutations(run_id) - if mutations is None: - raise HTTPException(status_code=404, detail=f"Run {run_id} not found") - return mutations - - -# --------------------------------------------------------------------------- -# Resource content endpoint (file viewer modal) -# --------------------------------------------------------------------------- - - -# Max bytes we'll stream from a RunResource. The modal viewer is not a -# download manager — anything bigger 413s so the browser doesn't OOM. -_RESOURCE_CONTENT_MAX_BYTES: int = 10 * 1024 * 1024 - - -@router.get("/{run_id}/resources/{resource_id}/content") -def get_resource_content(run_id: UUID, resource_id: UUID) -> FileResponse: - """Stream the blob bytes for a RunResource. - - Used by the dashboard's file-viewer modal. Enforces: - - resource must belong to the named run (no cross-run leaks); - - resolved path must sit under ``ERGON_BLOB_ROOT`` (traversal guard); - - size <= ``_RESOURCE_CONTENT_MAX_BYTES`` (413 otherwise). - """ - try: - blob = RunReadService().get_resource_blob(run_id, resource_id) - except (FileNotFoundError, OSError) as e: - raise HTTPException(status_code=404, detail="Resource blob missing on disk") from e - except ValueError as e: - message = str(e) - if message.startswith("resource-too-large:"): - size = int(message.removeprefix("resource-too-large:")) - raise HTTPException( - status_code=413, - detail=f"Resource content {size} bytes exceeds viewer limit " - f"({_RESOURCE_CONTENT_MAX_BYTES} bytes)", - ) from e - raise HTTPException(status_code=404, detail="Resource blob outside blob root") from e - - if blob is None: - raise HTTPException(status_code=404, detail=f"Resource {resource_id} not found") - - return FileResponse( - path=blob.path, - media_type=blob.media_type, - filename=blob.filename, - content_disposition_type="inline", - ) - - -# --------------------------------------------------------------------------- -# Training curves endpoint (RL observability) -# --------------------------------------------------------------------------- - - -@router.get("/training/curves", response_model=list[TrainingCurvePointDto]) -def get_training_curves( - definition_id: UUID | None = None, - cohort_id: UUID | None = None, -) -> list[TrainingCurvePointDto]: - """Return score-over-step data for checkpoint evaluations. - - Reads ``summary_json`` on ``RunRecord`` for checkpoint metadata - (``checkpoint_step``, ``checkpoint_path``) written by the eval - watcher, and aggregates ``RunTaskEvaluation.score`` per run. - - Filter by ``definition_id`` or ``cohort_id``. - """ - return RunReadService().list_training_curves( - definition_id=definition_id, - cohort_id=cohort_id, - ) - - -# --------------------------------------------------------------------------- -# Training sessions endpoints -# --------------------------------------------------------------------------- - - -@router.get("/training/sessions", response_model=list[TrainingSessionDto]) -def get_training_sessions( - definition_id: UUID | None = None, -) -> list[TrainingSessionDto]: - """List training sessions, optionally filtered by definition.""" - return RunReadService().list_training_sessions(definition_id=definition_id) - - -@router.get("/training/sessions/{session_id}/metrics", response_model=list[TrainingMetricDto]) -def get_training_metrics(session_id: UUID) -> list[TrainingMetricDto]: - """Get per-step training metrics for a session.""" - return RunReadService().list_training_metrics(session_id) diff --git a/ergon_core/ergon_core/core/api/schemas.py b/ergon_core/ergon_core/core/api/schemas.py deleted file mode 100644 index 1b0e3672..00000000 --- a/ergon_core/ergon_core/core/api/schemas.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Pydantic DTOs for the run detail API surface. - -Task structure comes from RunGraphNode + RunGraphEdge rows (the live graph), -not from ExperimentDefinitionTask. All task keys are RunGraphNode.id. - -""" - -from datetime import datetime -from typing import Any -from uuid import UUID - -from ergon_core.core.persistence.context.event_payloads import ( - ContextEventPayload, - ContextEventType, -) -from ergon_core.core.persistence.telemetry.evaluation_summary import EvalCriterionStatus -from ergon_core.core.runtime.services.graph_dto import GraphMutationRecordDto -from pydantic import BaseModel, ConfigDict, Field - - -def _to_camel(value: str) -> str: - head, *tail = value.split("_") - return head + "".join(part.capitalize() for part in tail) - - -class CamelModel(BaseModel): - """Base model that exposes camelCase JSON to the frontend.""" - - model_config = ConfigDict( - alias_generator=_to_camel, - populate_by_name=True, - extra="forbid", - ) - - -class RunTaskDto(CamelModel): - """REST projection of RunGraphNode for run detail pages. - - This is not the canonical graph schema; graph semantics live in - runtime/services/graph_dto.py and persistence/graph/status_conventions.py. - """ - - id: str - name: str - description: str - status: str - parent_id: str | None = None - child_ids: list[str] = Field(default_factory=list) - depends_on_ids: list[str] = Field(default_factory=list) - is_leaf: bool - level: int - assigned_worker_id: str | None = None - assigned_worker_slug: str | None = None - started_at: datetime | None = None - completed_at: datetime | None = None - - -class RunResourceDto(CamelModel): - id: str - task_id: str - task_execution_id: str - name: str - mime_type: str - file_path: str - size_bytes: int - created_at: datetime - - -class RunExecutionAttemptDto(CamelModel): - id: str - task_id: str - attempt_number: int - status: str - started_at: datetime | None = None - completed_at: datetime | None = None - final_assistant_message: str | None = None - error_message: str | None = None - score: float | None = None - agent_id: str | None = None - agent_name: str | None = None - evaluation_details: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] - output_resource_ids: list[str] = Field(default_factory=list) - - -class RunEvaluationCriterionDto(CamelModel): - id: str - stage_num: int - stage_name: str - criterion_num: int - criterion_slug: str - criterion_type: str - criterion_description: str - criterion_name: str - status: EvalCriterionStatus - passed: bool - weight: float - contribution: float - evaluation_input: str | None = None - score: float - max_score: float - feedback: str | None = None - model_reasoning: str | None = None - skipped_reason: str | None = None - evaluated_action_ids: list[str] = Field(default_factory=list) - evaluated_resource_ids: list[str] = Field(default_factory=list) - observation: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] - error: dict[str, Any] | None = None # slopcop: ignore[no-typing-any] - - -class RunTaskEvaluationDto(CamelModel): - id: str - run_id: str - task_id: str | None = None - evaluator_name: str - aggregation_rule: str - total_score: float - max_score: float - normalized_score: float - stages_evaluated: int - stages_passed: int - failed_gate: str | None = None - created_at: datetime - criterion_results: list[RunEvaluationCriterionDto] = Field(default_factory=list) - - -class RunSandboxCommandDto(CamelModel): - command: str - stdout: str | None = None - stderr: str | None = None - exit_code: int | None = None - duration_ms: int | None = None - timestamp: datetime - - -class RunSandboxDto(CamelModel): - sandbox_id: str - task_id: str - template: str | None = None - timeout_minutes: int - status: str - created_at: datetime - closed_at: datetime | None = None - close_reason: str | None = None - commands: list[RunSandboxCommandDto] = Field(default_factory=list) - - -class RunCommunicationMessageDto(CamelModel): - id: str - thread_id: str - thread_topic: str - run_id: str - task_id: str | None = None - task_execution_id: str | None = None - from_agent_id: str - to_agent_id: str - content: str - sequence_num: int - created_at: datetime - - -class RunCommunicationThreadDto(CamelModel): - id: str - run_id: str - task_id: str | None = None - topic: str - summary: str | None = None - agent_a_id: str - agent_b_id: str - created_at: datetime - updated_at: datetime - messages: list[RunCommunicationMessageDto] = Field(default_factory=list) - - -class RunContextEventDto(CamelModel): - id: UUID - run_id: UUID - task_execution_id: UUID - task_node_id: UUID - worker_binding_key: str - sequence: int - event_type: ContextEventType - payload: ContextEventPayload - created_at: datetime - started_at: datetime | None = None - completed_at: datetime | None = None - - -class RunSnapshotDto(CamelModel): - id: str - experiment_id: str - name: str - status: str - tasks: dict[str, RunTaskDto] = Field(default_factory=dict) - root_task_id: str = "" # slopcop: ignore[no-str-empty-default] - resources_by_task: dict[str, list[RunResourceDto]] = Field(default_factory=dict) - executions_by_task: dict[str, list[RunExecutionAttemptDto]] = Field(default_factory=dict) - evaluations_by_task: dict[str, RunTaskEvaluationDto] = Field(default_factory=dict) - sandboxes_by_task: dict[str, RunSandboxDto] = Field(default_factory=dict) - context_events_by_task: dict[str, list[RunContextEventDto]] = Field(default_factory=dict) - threads: list[RunCommunicationThreadDto] = Field(default_factory=list) - started_at: datetime | None = None - completed_at: datetime | None = None - duration_seconds: float | None = None - total_tasks: int = 0 - total_leaf_tasks: int = 0 - completed_tasks: int = 0 - failed_tasks: int = 0 - running_tasks: int = 0 - cancelled_tasks: int = 0 - final_score: float | None = None - error: str | None = None - - -# --------------------------------------------------------------------------- -# Training DTOs (RL observability) -# --------------------------------------------------------------------------- - - -class TrainingCurvePointDto(CamelModel): - run_id: str - step: int - mean_score: float - benchmark_type: str | None = None - created_at: str | None = None - - -class TrainingSessionDto(CamelModel): - id: str - experiment_definition_id: str - model_name: str - status: str - started_at: str | None = None - completed_at: str | None = None - output_dir: str | None = None - total_steps: int | None = None - final_loss: float | None = None - - -class TrainingMetricDto(CamelModel): - step: int - epoch: float | None = None - loss: float | None = None - grad_norm: float | None = None - learning_rate: float | None = None - reward_mean: float | None = None - reward_std: float | None = None - entropy: float | None = None - completion_mean_length: float | None = None - step_time_s: float | None = None diff --git a/ergon_core/ergon_core/core/rest_api/__init__.py b/ergon_core/ergon_core/core/rest_api/__init__.py new file mode 100644 index 00000000..a9252b91 --- /dev/null +++ b/ergon_core/ergon_core/core/rest_api/__init__.py @@ -0,0 +1 @@ +"""Internal REST API package.""" diff --git a/ergon_core/ergon_core/core/api/app.py b/ergon_core/ergon_core/core/rest_api/app.py similarity index 74% rename from ergon_core/ergon_core/core/api/app.py rename to ergon_core/ergon_core/core/rest_api/app.py index 662b39a8..70be2de5 100644 --- a/ergon_core/ergon_core/core/api/app.py +++ b/ergon_core/ergon_core/core/rest_api/app.py @@ -21,23 +21,24 @@ ) import inngest.fast_api -from ergon_core.core.api.cohorts import router as cohorts_router -from ergon_core.core.api.experiments import router as experiments_router -from ergon_core.core.api.rollouts import router as rollouts_router -from ergon_core.core.api.runs import router as runs_router -from ergon_core.core.api.test_harness import router as _test_harness_router -from ergon_core.core.dashboard.provider import init_dashboard_emitter, reset_dashboard_emitter +from ergon_core.api.registry import registry +from ergon_core.core.rest_api.cohorts import router as cohorts_router +from ergon_core.core.rest_api.experiments import router as experiments_router +from ergon_core.core.rest_api.rollouts import router as rollouts_router +from ergon_core.core.rest_api.runs import router as runs_router +from ergon_core.core.rest_api.test_harness import router as _test_harness_router +from ergon_core.core.infrastructure.dashboard.provider import init_dashboard_emitter, reset_dashboard_emitter from ergon_core.core.persistence.shared.db import ensure_db, get_session -from ergon_core.core.sandbox.event_sink import ( +from ergon_core.core.infrastructure.sandbox.event_sink import ( CompoundSandboxEventSink, DashboardEmitterSandboxEventSink, PostgresSandboxEventSink, ) -from ergon_core.core.sandbox.manager import DefaultSandboxManager +from ergon_core.core.infrastructure.sandbox.manager import DefaultSandboxManager from ergon_core.core.rl.rollout_service import RolloutService -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.inngest.registry import ALL_FUNCTIONS -from ergon_core.core.settings import Settings, settings +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.infrastructure.inngest.registry import ALL_FUNCTIONS +from ergon_core.core.shared.settings import Settings, settings from fastapi import FastAPI logger = logging.getLogger(__name__) @@ -70,23 +71,20 @@ async def lifespan(app: FastAPI): dashboard_emitter = init_dashboard_emitter(enabled=True) app.state.dashboard_emitter = dashboard_emitter - # Wire the dashboard event sink on every sandbox manager subclass. - # Import ergon_builtins here (deferred) to avoid a circular import at - # module level; ergon_builtins imports ergon_core, not the reverse. - from ergon_builtins.registry import SANDBOX_MANAGERS + _run_startup_plugins(settings.startup_plugins) + # Wire the dashboard event sink on every registered sandbox manager class. sink = CompoundSandboxEventSink( DashboardEmitterSandboxEventSink(dashboard_emitter), PostgresSandboxEventSink(), ) DefaultSandboxManager.set_event_sink(sink) - for manager_cls in SANDBOX_MANAGERS.values(): + for manager_cls in registry.sandbox_managers.values(): manager_cls.set_event_sink(sink) logger.info( "sandbox event sink wired on %d manager subclass(es)", - 1 + len(SANDBOX_MANAGERS), + 1 + len(registry.sandbox_managers), ) - _run_startup_plugins(settings.startup_plugins) logger.info("app startup complete — all subsystems initialised") try: diff --git a/ergon_core/ergon_core/core/api/cohorts.py b/ergon_core/ergon_core/core/rest_api/cohorts.py similarity index 91% rename from ergon_core/ergon_core/core/api/cohorts.py rename to ergon_core/ergon_core/core/rest_api/cohorts.py index 9a997c65..9f7a8c55 100644 --- a/ergon_core/ergon_core/core/api/cohorts.py +++ b/ergon_core/ergon_core/core/rest_api/cohorts.py @@ -2,12 +2,12 @@ from uuid import UUID -from ergon_core.core.runtime.services.cohort_schemas import ( +from ergon_core.core.application.read_models.models import ( CohortDetailDto, CohortSummaryDto, UpdateCohortRequest, ) -from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service +from ergon_core.core.application.read_models.cohorts import experiment_cohort_service from fastapi import APIRouter, HTTPException, Query router = APIRouter(prefix="/cohorts", tags=["cohorts"]) diff --git a/ergon_core/ergon_core/core/api/experiments.py b/ergon_core/ergon_core/core/rest_api/experiments.py similarity index 74% rename from ergon_core/ergon_core/core/api/experiments.py rename to ergon_core/ergon_core/core/rest_api/experiments.py index 5e664746..5dbdd7b3 100644 --- a/ergon_core/ergon_core/core/api/experiments.py +++ b/ergon_core/ergon_core/core/rest_api/experiments.py @@ -2,16 +2,15 @@ from uuid import UUID -from ergon_core.core.runtime.services.experiment_definition_service import ( - ExperimentDefinitionService, +from ergon_core.core.application.experiments.service import ( + ExperimentService, ) -from ergon_core.core.runtime.services.experiment_launch_service import ExperimentLaunchService -from ergon_core.core.runtime.services.experiment_read_service import ( +from ergon_core.core.application.read_models.experiments import ( ExperimentDetailDto, ExperimentReadService, ExperimentSummaryDto, ) -from ergon_core.core.runtime.services.experiment_schemas import ( +from ergon_core.core.application.experiments.models import ( ExperimentDefineRequest, ExperimentDefineResult, ExperimentRunRequest, @@ -37,7 +36,7 @@ def get_experiment(experiment_id: UUID) -> ExperimentDetailDto: @router.post("/define", response_model=ExperimentDefineResult, status_code=201) def define_experiment(request: ExperimentDefineRequest) -> ExperimentDefineResult: - return ExperimentDefinitionService().define_benchmark_experiment(request) + return ExperimentService().define_benchmark_experiment(request) @router.post("/{experiment_id}/run", response_model=ExperimentRunResult, status_code=202) @@ -45,4 +44,4 @@ async def run_experiment(experiment_id: UUID, request: ExperimentRunRequest | No launch_request = request or ExperimentRunRequest(experiment_id=experiment_id) if launch_request.experiment_id != experiment_id: raise HTTPException(status_code=400, detail="experiment_id mismatch") - return await ExperimentLaunchService().run_experiment(launch_request) + return await ExperimentService().run_experiment(launch_request) diff --git a/ergon_core/ergon_core/core/api/rollouts.py b/ergon_core/ergon_core/core/rest_api/rollouts.py similarity index 100% rename from ergon_core/ergon_core/core/api/rollouts.py rename to ergon_core/ergon_core/core/rest_api/rollouts.py diff --git a/ergon_core/ergon_core/core/rest_api/runs.py b/ergon_core/ergon_core/core/rest_api/runs.py new file mode 100644 index 00000000..a787df04 --- /dev/null +++ b/ergon_core/ergon_core/core/rest_api/runs.py @@ -0,0 +1,88 @@ +"""FastAPI router for persisted run-detail snapshots.""" + +from uuid import UUID + +from ergon_core.core.application.read_models.models import ( + RunSnapshotDto, + TrainingCurvePointDto, + TrainingMetricDto, + TrainingSessionDto, +) +from ergon_core.core.application.graph.models import GraphMutationRecordDto +from ergon_core.core.application.read_models.errors import ResourceTooLargeError +from ergon_core.core.application.read_models.runs import RunReadService +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +router = APIRouter(prefix="/runs", tags=["runs"]) + + +def build_run_snapshot(run_id: UUID) -> RunSnapshotDto | None: + return RunReadService().build_run_snapshot(run_id) + + +@router.get("/{run_id}", response_model=RunSnapshotDto) +def get_run(run_id: UUID) -> RunSnapshotDto: + """Get a persisted run-detail snapshot suitable for frontend hydration.""" + snapshot = build_run_snapshot(run_id) + if snapshot is None: + raise HTTPException(status_code=404, detail=f"Run {run_id} not found") + return snapshot + + +@router.get("/{run_id}/mutations", response_model=list[GraphMutationRecordDto]) +def get_mutations(run_id: UUID) -> list[GraphMutationRecordDto]: + """Return the append-only mutation log for a run, ordered by sequence.""" + mutations = RunReadService().list_mutations(run_id) + if mutations is None: + raise HTTPException(status_code=404, detail=f"Run {run_id} not found") + return mutations + + +@router.get("/{run_id}/resources/{resource_id}/content") +def get_resource_content(run_id: UUID, resource_id: UUID) -> FileResponse: + """Stream the blob bytes for a RunResource.""" + try: + blob = RunReadService().get_resource_blob(run_id, resource_id) + except (FileNotFoundError, OSError) as e: + raise HTTPException(status_code=404, detail="Resource blob missing on disk") from e + except ResourceTooLargeError as e: + raise HTTPException(status_code=413, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=404, detail="Resource blob outside blob root") from e + + if blob is None: + raise HTTPException(status_code=404, detail=f"Resource {resource_id} not found") + + return FileResponse( + path=blob.path, + media_type=blob.media_type, + filename=blob.filename, + content_disposition_type="inline", + ) + + +@router.get("/training/curves", response_model=list[TrainingCurvePointDto]) +def get_training_curves( + definition_id: UUID | None = None, + cohort_id: UUID | None = None, +) -> list[TrainingCurvePointDto]: + """Return score-over-step data for checkpoint evaluations.""" + return RunReadService().list_training_curves( + definition_id=definition_id, + cohort_id=cohort_id, + ) + + +@router.get("/training/sessions", response_model=list[TrainingSessionDto]) +def get_training_sessions( + definition_id: UUID | None = None, +) -> list[TrainingSessionDto]: + """List training sessions, optionally filtered by definition.""" + return RunReadService().list_training_sessions(definition_id=definition_id) + + +@router.get("/training/sessions/{session_id}/metrics", response_model=list[TrainingMetricDto]) +def get_training_metrics(session_id: UUID) -> list[TrainingMetricDto]: + """Get per-step training metrics for a session.""" + return RunReadService().list_training_metrics(session_id) diff --git a/ergon_core/ergon_core/core/api/test_harness.py b/ergon_core/ergon_core/core/rest_api/test_harness.py similarity index 95% rename from ergon_core/ergon_core/core/api/test_harness.py rename to ergon_core/ergon_core/core/rest_api/test_harness.py index 4ed7f619..ae8e714b 100644 --- a/ergon_core/ergon_core/core/api/test_harness.py +++ b/ergon_core/ergon_core/core/rest_api/test_harness.py @@ -20,7 +20,6 @@ from uuid import UUID import inngest -from ergon_cli.composition import build_experiment from ergon_core.core.persistence.context.models import RunContextEvent from ergon_core.core.persistence.graph.models import RunGraphMutation, RunGraphNode from ergon_core.core.persistence.shared.db import get_engine @@ -34,14 +33,13 @@ RunTaskExecution, Thread, ) -from ergon_core.core.runtime.events.task_events import WorkflowStartedEvent -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service -from ergon_core.core.runtime.services.experiment_definition_service import ( - ExperimentDefinitionService, +from ergon_core.core.application.events.task_events import WorkflowStartedEvent +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.application.read_models.cohorts import experiment_cohort_service +from ergon_core.core.application.experiments.service import ( + ExperimentService, ) -from ergon_core.core.runtime.services.experiment_launch_service import ExperimentLaunchService -from ergon_core.core.runtime.services.experiment_schemas import ( +from ergon_core.core.application.experiments.models import ( ExperimentDefineRequest, ExperimentRunRequest, ) @@ -441,6 +439,8 @@ class SubmitCohortRequest(BaseModel): benchmark_slug: str slots: list[CohortSlotRequest] cohort_key: str + sandbox_slug: str | None = None + dependency_extras: tuple[str, ...] = ("none",) # Smoke workers don't hit an LLM; the field is required downstream # only because ``WorkerSpec`` models it. Default matches the CLI. model: str = "openai:gpt-4o" @@ -468,7 +468,8 @@ async def submit_cohort(body: SubmitCohortRequest) -> SubmitCohortResponse: run_ids: list[UUID] = [] for slot in body.slots: - defined = ExperimentDefinitionService().define_benchmark_experiment( + experiment_service = ExperimentService() + defined = experiment_service.define_benchmark_experiment( ExperimentDefineRequest( benchmark_slug=body.benchmark_slug, cohort_id=cohort.id, @@ -476,10 +477,12 @@ async def submit_cohort(body: SubmitCohortRequest) -> SubmitCohortResponse: default_model_target=body.model, default_worker_team={"primary": slot.worker_slug}, default_evaluator_slug=slot.evaluator_slug, + sandbox_slug=body.sandbox_slug or body.benchmark_slug, + dependency_extras=body.dependency_extras, metadata={"source": "test-harness"}, ) ) - launched = await ExperimentLaunchService().run_experiment( + launched = await experiment_service.run_experiment( ExperimentRunRequest(experiment_id=defined.experiment_id) ) run_ids.extend(launched.run_ids) From c214063f4feb71a7b5903717be8bd35f16677a6f Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:46 +0100 Subject: [PATCH 60/66] refactor: update persistence imports for new core layout Made-with: Cursor --- .../persistence/context/event_payloads.py | 2 +- .../core/persistence/context/models.py | 2 +- .../core/persistence/context/repository.py | 145 ------ .../core/persistence/definitions/models.py | 4 +- .../core/persistence/graph/models.py | 6 +- .../ergon_core/core/persistence/queries.py | 429 ------------------ .../core/persistence/saved_specs/models.py | 4 +- .../ergon_core/core/persistence/shared/db.py | 2 +- .../telemetry/evaluation_summary.py | 8 +- .../core/persistence/telemetry/models.py | 20 +- .../persistence/telemetry/repositories.py | 26 +- ergon_core/ergon_core/core/rl/extraction.py | 2 +- .../ergon_core/core/rl/rollout_service.py | 2 +- ergon_core/migrations/env.py | 2 +- ...3e4f5a6b7_add_sandbox_dependency_fields.py | 53 +++ ergon_infra/ergon_infra/training/callback.py | 2 +- pyproject.toml | 28 +- 17 files changed, 102 insertions(+), 635 deletions(-) delete mode 100644 ergon_core/ergon_core/core/persistence/context/repository.py delete mode 100644 ergon_core/ergon_core/core/persistence/queries.py create mode 100644 ergon_core/migrations/versions/c2d3e4f5a6b7_add_sandbox_dependency_fields.py diff --git a/ergon_core/ergon_core/core/persistence/context/event_payloads.py b/ergon_core/ergon_core/core/persistence/context/event_payloads.py index d5286dd5..8df20ad5 100644 --- a/ergon_core/ergon_core/core/persistence/context/event_payloads.py +++ b/ergon_core/ergon_core/core/persistence/context/event_payloads.py @@ -6,7 +6,7 @@ from typing import Literal -from ergon_core.core.generation import ContextPart, ContextPartChunk, ContextPartChunkLog +from ergon_core.core.domain.generation.context_parts import ContextPart, ContextPartChunk, ContextPartChunkLog ContextEventType = Literal[ "system_prompt", diff --git a/ergon_core/ergon_core/core/persistence/context/models.py b/ergon_core/ergon_core/core/persistence/context/models.py index 786152c4..7ef061a4 100644 --- a/ergon_core/ergon_core/core/persistence/context/models.py +++ b/ergon_core/ergon_core/core/persistence/context/models.py @@ -6,7 +6,7 @@ from uuid import UUID import sqlalchemy as sa -from ergon_core.core.generation import ContextPartChunkLog +from ergon_core.core.domain.generation.context_parts import ContextPartChunkLog from ergon_core.core.persistence.shared.ids import new_id from pydantic import TypeAdapter from sqlalchemy import JSON, Column, DateTime diff --git a/ergon_core/ergon_core/core/persistence/context/repository.py b/ergon_core/ergon_core/core/persistence/context/repository.py deleted file mode 100644 index 2db7ec29..00000000 --- a/ergon_core/ergon_core/core/persistence/context/repository.py +++ /dev/null @@ -1,145 +0,0 @@ -# ergon_core/ergon_core/core/persistence/context/repository.py -"""Append-only write path for run_context_events. - -Repository maintains per-execution sequence counters in memory (not DB). -This is safe because each execution runs in a single Inngest invocation. -""" - -import logging -from collections.abc import Awaitable, Callable -from datetime import UTC, datetime -from uuid import UUID, uuid4 - -from ergon_core.core.generation import ( - AssistantTextPart, - ContextPartChunk, - ContextPartChunkLog, - SystemPromptPart, - ThinkingPart, - ToolCallPart, - ToolResultPart, - UserMessagePart, -) -from ergon_core.core.persistence.context.models import RunContextEvent -from sqlmodel import Session, select - -logger = logging.getLogger(__name__) - - -class ContextEventRepository: - """Append-only write path for run_context_events.""" - - def __init__(self) -> None: - self._listeners: list[Callable[[RunContextEvent], Awaitable[None]]] = [] - self._sequence_counters: dict[UUID, int] = {} - self._active_turn_ids: dict[UUID, str] = {} - - def add_listener(self, listener: Callable[[RunContextEvent], Awaitable[None]]) -> None: - self._listeners.append(listener) - - def _next_sequence(self, execution_id: UUID) -> int: - return self._sequence_counters.get(execution_id, 0) - - def _make_event( - self, - run_id: UUID, - execution_id: UUID, - worker_binding_key: str, - sequence: int, - payload: ContextPartChunkLog, - *, - started_at: datetime | None = None, - completed_at: datetime | None = None, - policy_version: str | None = None, - ) -> RunContextEvent: - return RunContextEvent( - run_id=run_id, - task_execution_id=execution_id, - worker_binding_key=worker_binding_key, - sequence=sequence, - event_type=payload.part.part_kind, - payload=payload.model_dump(mode="json"), - started_at=started_at, - completed_at=completed_at, - policy_version=policy_version, - ) - - def _turn_id_for_chunk(self, execution_id: UUID, chunk: ContextPartChunk) -> str | None: - part = chunk.part - if isinstance(part, (AssistantTextPart, ThinkingPart, ToolCallPart)): - turn_id = self._active_turn_ids.get(execution_id) - if turn_id is None: - turn_id = str(uuid4()) - self._active_turn_ids[execution_id] = turn_id - return turn_id - if isinstance(part, (SystemPromptPart, UserMessagePart, ToolResultPart)): - self._active_turn_ids.pop(execution_id, None) - return None - return None - - async def persist_chunk( - self, - session: Session, - *, - run_id: UUID, - execution_id: UUID, - worker_binding_key: str, - chunk: ContextPartChunk, - started_at: datetime | None = None, - completed_at: datetime | None = None, - policy_version: str | None = None, - ) -> RunContextEvent: - """Enrich and persist one worker-emitted context stream chunk.""" - seq = self._next_sequence(execution_id) - now = datetime.now(UTC) - event_started_at = started_at or now - event_completed_at = completed_at or now - payload = ContextPartChunkLog( - part=chunk.part, - token_ids=chunk.token_ids, - logprobs=chunk.logprobs, - sequence=seq, - worker_binding_key=worker_binding_key, - turn_id=self._turn_id_for_chunk(execution_id, chunk), - started_at=event_started_at, - completed_at=event_completed_at, - policy_version=policy_version, - ) - event = self._make_event( - run_id, - execution_id, - worker_binding_key, - seq, - payload, - started_at=payload.started_at, - completed_at=payload.completed_at, - policy_version=payload.policy_version, - ) - self._sequence_counters[execution_id] = seq + 1 - - session.add(event) - session.commit() - - for listener in self._listeners: - try: - await listener(event) - except Exception: # slopcop: ignore[no-broad-except] - logger.warning("Context event listener failed", exc_info=True) - - return event - - def get_for_execution(self, session: Session, execution_id: UUID) -> list[RunContextEvent]: - stmt = ( - select(RunContextEvent) - .where(RunContextEvent.task_execution_id == execution_id) - .order_by(RunContextEvent.sequence) - ) - return list(session.exec(stmt).all()) - - def get_for_run(self, session: Session, run_id: UUID) -> list[RunContextEvent]: - stmt = ( - select(RunContextEvent) - .where(RunContextEvent.run_id == run_id) - .order_by(RunContextEvent.task_execution_id, RunContextEvent.sequence) - ) - return list(session.exec(stmt).all()) diff --git a/ergon_core/ergon_core/core/persistence/definitions/models.py b/ergon_core/ergon_core/core/persistence/definitions/models.py index cc89bab3..c88fc683 100644 --- a/ergon_core/ergon_core/core/persistence/definitions/models.py +++ b/ergon_core/ergon_core/core/persistence/definitions/models.py @@ -9,8 +9,8 @@ from typing import TypeVar from uuid import UUID, uuid4 -from ergon_core.core.json_types import JsonObject -from ergon_core.core.utils import utcnow as _utcnow +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.shared.utils import utcnow as _utcnow from pydantic import BaseModel, model_validator from sqlalchemy import JSON, Column, DateTime from sqlmodel import Field, SQLModel diff --git a/ergon_core/ergon_core/core/persistence/graph/models.py b/ergon_core/ergon_core/core/persistence/graph/models.py index 8c123094..fc749af2 100644 --- a/ergon_core/ergon_core/core/persistence/graph/models.py +++ b/ergon_core/ergon_core/core/persistence/graph/models.py @@ -14,8 +14,8 @@ from typing import Literal from uuid import UUID, uuid4 -from ergon_core.core.json_types import JsonObject -from ergon_core.core.utils import utcnow as _utcnow +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.shared.utils import utcnow as _utcnow from pydantic import model_validator from sqlalchemy import JSON, Column, DateTime, Index from sqlmodel import Field, SQLModel @@ -77,7 +77,7 @@ class RunGraphNode(SQLModel, table=True): assigned_worker_slug: str | None = Field( default=None, description=( - "WORKERS registry slug assigned to execute this node, for example " + "Worker registry slug assigned to execute this node, for example " "'researchrubrics-researcher' or 'canonical-smoke'." ), ) diff --git a/ergon_core/ergon_core/core/persistence/queries.py b/ergon_core/ergon_core/core/persistence/queries.py deleted file mode 100644 index c236c551..00000000 --- a/ergon_core/ergon_core/core/persistence/queries.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Database query methods organized by entity. - -Provides the ``queries`` singleton — a namespace that exposes typed, -session-managed query helpers for every table in the schema. Each method -opens a session, performs the query, and closes the session; no complex -transaction management is needed at this layer. -""" - -from typing import Any, Generic, Type, TypeVar -from uuid import UUID - -from ergon_core.core.json_types import JsonObject -from ergon_core.core.persistence.definitions.models import ( - ExperimentDefinition, - ExperimentDefinitionEvaluator, - ExperimentDefinitionInstance, - ExperimentDefinitionTask, - ExperimentDefinitionTaskAssignment, - ExperimentDefinitionTaskDependency, - ExperimentDefinitionTaskEvaluator, - ExperimentDefinitionWorker, -) -from ergon_core.core.persistence.graph.models import RunGraphNode -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus -from ergon_core.core.persistence.telemetry.models import ( - ExperimentRecord, - RunRecord, - RunResource, - RunTaskExecution, -) -from pydantic import BaseModel -from sqlmodel import SQLModel, col, desc, select - -T = TypeVar("T", bound=SQLModel) -PayloadModelT = TypeVar("PayloadModelT", bound=BaseModel) - -# --------------------------------------------------------------------------- -# Base -# --------------------------------------------------------------------------- - - -class BaseQueries(Generic[T]): - """Base query class with common CRUD operations.""" - - def __init__(self, model: Type[T]): - self.model = model - - def get(self, id: UUID) -> T | None: - with get_session() as session: - return session.get(self.model, id) - - def create(self, entity: T) -> T: - entity_data = entity.model_dump(exclude={"id"}, exclude_none=False) - new_entity = self.model.model_validate(entity_data) - with get_session() as session: - session.add(new_entity) - session.commit() - session.refresh(new_entity) - return new_entity - - def update(self, entity: T) -> T: - entity_id: UUID | None = entity.model_dump().get("id") - if entity_id is None: - raise ValueError(f"{self.model.__name__} id must be set for update") - with get_session() as session: - existing = session.get(self.model, entity_id) - if existing is None: - raise ValueError(f"{self.model.__name__} {entity_id} not found") - for key, value in entity.model_dump(exclude_none=False).items(): - setattr(existing, key, value) - session.commit() - session.refresh(existing) - return existing - - def list_all(self, *, limit: int | None = None) -> list[T]: - with get_session() as session: - stmt = select(self.model) - if limit is not None: - stmt = stmt.limit(limit) - return list(session.exec(stmt).all()) - - -# --------------------------------------------------------------------------- -# Runs -# --------------------------------------------------------------------------- - - -class RunsQueries(BaseQueries[RunRecord]): - def __init__(self) -> None: - super().__init__(RunRecord) - - def list_by_definition(self, definition_id: UUID) -> list[RunRecord]: - with get_session() as session: - stmt = ( - select(RunRecord) - .where(RunRecord.workflow_definition_id == definition_id) - .order_by(desc(RunRecord.created_at)) - ) - return list(session.exec(stmt).all()) - - def get_by_status(self, status: RunStatus | str) -> list[RunRecord]: - with get_session() as session: - stmt = select(RunRecord).where(RunRecord.status == status) - return list(session.exec(stmt).all()) - - def get_recent(self, limit: int = 10) -> list[RunRecord]: - with get_session() as session: - stmt = select(RunRecord).order_by(desc(RunRecord.created_at)).limit(limit) - return list(session.exec(stmt).all()) - - def get_cohort_id(self, run_id: UUID) -> UUID | None: - with get_session() as session: - run = session.get(RunRecord, run_id) - if run is None: - return None - experiment = session.get(ExperimentRecord, run.experiment_id) - if experiment is None: - return None - return experiment.cohort_id - - -# --------------------------------------------------------------------------- -# Definitions -# --------------------------------------------------------------------------- - - -class DefinitionsQueries(BaseQueries[ExperimentDefinition]): - def __init__(self) -> None: - super().__init__(ExperimentDefinition) - - def get_by_benchmark_type(self, benchmark_type: str) -> list[ExperimentDefinition]: - with get_session() as session: - stmt = select(ExperimentDefinition).where( - ExperimentDefinition.benchmark_type == benchmark_type - ) - return list(session.exec(stmt).all()) - - def get_workers(self, definition_id: UUID) -> list[ExperimentDefinitionWorker]: - with get_session() as session: - stmt = select(ExperimentDefinitionWorker).where( - ExperimentDefinitionWorker.experiment_definition_id == definition_id - ) - return list(session.exec(stmt).all()) - - def get_evaluators(self, definition_id: UUID) -> list[ExperimentDefinitionEvaluator]: - with get_session() as session: - stmt = select(ExperimentDefinitionEvaluator).where( - ExperimentDefinitionEvaluator.experiment_definition_id == definition_id - ) - return list(session.exec(stmt).all()) - - def get_instances(self, definition_id: UUID) -> list[ExperimentDefinitionInstance]: - with get_session() as session: - stmt = select(ExperimentDefinitionInstance).where( - ExperimentDefinitionInstance.experiment_definition_id == definition_id - ) - return list(session.exec(stmt).all()) - - def get_tasks(self, definition_id: UUID) -> list[ExperimentDefinitionTask]: - with get_session() as session: - stmt = select(ExperimentDefinitionTask).where( - ExperimentDefinitionTask.experiment_definition_id == definition_id - ) - return list(session.exec(stmt).all()) - - def get_task_with_instance( - self, - task_id: UUID, - ) -> tuple[ExperimentDefinitionTask, ExperimentDefinitionInstance]: - with get_session() as session: - task = session.get(ExperimentDefinitionTask, task_id) - if task is None: - raise ValueError(f"ExperimentDefinitionTask {task_id} not found") - instance = session.get(ExperimentDefinitionInstance, task.instance_id) - if instance is None: - raise ValueError(f"ExperimentDefinitionInstance {task.instance_id} not found") - return task, instance - - def get_task_dependencies( - self, definition_id: UUID - ) -> list[ExperimentDefinitionTaskDependency]: - with get_session() as session: - stmt = select(ExperimentDefinitionTaskDependency).where( - ExperimentDefinitionTaskDependency.experiment_definition_id == definition_id - ) - return list(session.exec(stmt).all()) - - def get_task_assignments(self, definition_id: UUID) -> list[ExperimentDefinitionTaskAssignment]: - with get_session() as session: - stmt = select(ExperimentDefinitionTaskAssignment).where( - ExperimentDefinitionTaskAssignment.experiment_definition_id == definition_id - ) - return list(session.exec(stmt).all()) - - def get_task_evaluators(self, definition_id: UUID) -> list[ExperimentDefinitionTaskEvaluator]: - with get_session() as session: - stmt = select(ExperimentDefinitionTaskEvaluator).where( - ExperimentDefinitionTaskEvaluator.experiment_definition_id == definition_id - ) - return list(session.exec(stmt).all()) - - -# --------------------------------------------------------------------------- -# Task Executions -# --------------------------------------------------------------------------- - - -class TaskExecutionsQueries(BaseQueries[RunTaskExecution]): - def __init__(self) -> None: - super().__init__(RunTaskExecution) - - def list_by_run(self, run_id: UUID) -> list[RunTaskExecution]: - with get_session() as session: - stmt = select(RunTaskExecution).where(RunTaskExecution.run_id == run_id) - return list(session.exec(stmt).all()) - - def get_by_task(self, run_id: UUID, definition_task_id: UUID) -> list[RunTaskExecution]: - with get_session() as session: - stmt = ( - select(RunTaskExecution) - .where( - RunTaskExecution.run_id == run_id, - RunTaskExecution.definition_task_id == definition_task_id, - ) - .order_by(desc(RunTaskExecution.attempt_number)) - ) - return list(session.exec(stmt).all()) - - def get_latest_by_task(self, run_id: UUID, definition_task_id: UUID) -> RunTaskExecution | None: - with get_session() as session: - stmt = ( - select(RunTaskExecution) - .where( - RunTaskExecution.run_id == run_id, - RunTaskExecution.definition_task_id == definition_task_id, - ) - .order_by(desc(RunTaskExecution.attempt_number)) - ) - return session.exec(stmt).first() - - def get_by_status(self, status: TaskExecutionStatus | str) -> list[RunTaskExecution]: - with get_session() as session: - stmt = select(RunTaskExecution).where(RunTaskExecution.status == status) - return list(session.exec(stmt).all()) - - def list_children_of(self, parent_id: UUID) -> list[RunTaskExecution]: - """Return direct child task executions of the given parent execution. - - Uses RunGraphNode.parent_node_id for containment lookup instead - of edge traversal. The parent execution's node_id is looked up, - then all child nodes with that parent_node_id are found, and - their executions returned. - """ - with get_session() as session: - parent = session.get(RunTaskExecution, parent_id) - if parent is None or parent.node_id is None: - return [] - child_node_ids_stmt = select(RunGraphNode.id).where( - RunGraphNode.parent_node_id == parent.node_id - ) - stmt = select(RunTaskExecution).where( - col(RunTaskExecution.node_id).in_(child_node_ids_stmt) - ) - return list(session.exec(stmt).all()) - - def update_status( - self, - execution_id: UUID, - status: TaskExecutionStatus | str, - **kwargs: Any, # slopcop: ignore[no-typing-any] - ) -> RunTaskExecution: - with get_session() as session: - existing = session.get(RunTaskExecution, execution_id) - if existing is None: - raise ValueError(f"RunTaskExecution {execution_id} not found") - existing.status = status - for key, value in kwargs.items(): - if value is not None: - setattr(existing, key, value) - session.commit() - session.refresh(existing) - return existing - - def get_task_payload( - self, - task_execution_id: UUID, - payload_model: type[PayloadModelT], - ) -> PayloadModelT | None: - """Return the immutable task_payload for a task execution. - - Joins ``run_task_executions`` → ``experiment_definition_tasks``. - Returns ``None`` if the execution row does not exist or its - ``definition_task_id`` points at nothing (run-scoped tasks that - weren't tied to a definition — should not happen in normal - benchmark flow). - """ - with get_session() as session: - stmt = ( - select(ExperimentDefinitionTask) - .join( - RunTaskExecution, - RunTaskExecution.definition_task_id == ExperimentDefinitionTask.id, - ) - .where(RunTaskExecution.id == task_execution_id) - ) - result = session.exec(stmt).first() - if result is None: - return None - return result.task_payload_as(payload_model) - - -# --------------------------------------------------------------------------- -# Resources -# --------------------------------------------------------------------------- - - -class ResourcesQueries(BaseQueries[RunResource]): - def __init__(self) -> None: - super().__init__(RunResource) - - def list_by_run(self, run_id: UUID) -> list[RunResource]: - with get_session() as session: - stmt = select(RunResource).where(RunResource.run_id == run_id) - return list(session.exec(stmt).all()) - - def list_by_execution(self, task_execution_id: UUID) -> list[RunResource]: - with get_session() as session: - stmt = select(RunResource).where(RunResource.task_execution_id == task_execution_id) - return list(session.exec(stmt).all()) - - # --- append-only-log reads ------------------------------------------- - - def latest_by_path( - self, - *, - task_execution_id: UUID, - file_path: str, - ) -> RunResource | None: - """Most-recently-inserted row for (task_execution_id, file_path).""" - with get_session() as session: - stmt = ( - select(RunResource) - .where( - RunResource.task_execution_id == task_execution_id, - RunResource.file_path == file_path, - ) - .order_by(RunResource.created_at.desc(), RunResource.id.desc()) - .limit(1) - ) - return session.exec(stmt).first() - - def find_by_hash( - self, - *, - task_execution_id: UUID, - content_hash: str, - ) -> RunResource | None: - """Any row in this task execution whose content_hash matches.""" - with get_session() as session: - stmt = ( - select(RunResource) - .where( - RunResource.task_execution_id == task_execution_id, - RunResource.content_hash == content_hash, - ) - .limit(1) - ) - return session.exec(stmt).first() - - # --- append ---------------------------------------------------------- - - def append( # slopcop: ignore[max-function-params] - self, - *, - run_id: UUID, - task_execution_id: UUID, - kind: str, - name: str, - mime_type: str, - file_path: str, - size_bytes: int, - error: str | None, - content_hash: str | None, - metadata: JsonObject | None = None, - copied_from_resource_id: UUID | None = None, - ) -> RunResource: - """Append one row to the log. Never updates.""" - with get_session() as session: - row = RunResource( - run_id=run_id, - task_execution_id=task_execution_id, - kind=kind, - name=name, - mime_type=mime_type, - file_path=file_path, - size_bytes=size_bytes, - error=error, - content_hash=content_hash, - metadata_json=metadata or {}, - copied_from_resource_id=copied_from_resource_id, - ) - session.add(row) - session.commit() - session.refresh(row) - return row - - -# --------------------------------------------------------------------------- -# Namespace Singleton -# --------------------------------------------------------------------------- - - -class Queries: - """Namespace singleton providing typed query methods for all tables.""" - - runs: RunsQueries - definitions: DefinitionsQueries - task_executions: TaskExecutionsQueries - resources: ResourcesQueries - - def __init__(self) -> None: - self.runs = RunsQueries() - self.definitions = DefinitionsQueries() - self.task_executions = TaskExecutionsQueries() - self.resources = ResourcesQueries() - - -queries = Queries() diff --git a/ergon_core/ergon_core/core/persistence/saved_specs/models.py b/ergon_core/ergon_core/core/persistence/saved_specs/models.py index f1ee2c28..47d21fd0 100644 --- a/ergon_core/ergon_core/core/persistence/saved_specs/models.py +++ b/ergon_core/ergon_core/core/persistence/saved_specs/models.py @@ -5,8 +5,8 @@ from datetime import datetime from uuid import UUID, uuid4 -from ergon_core.core.json_types import JsonObject -from ergon_core.core.utils import utcnow as _utcnow +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.shared.utils import utcnow as _utcnow from pydantic import model_validator from sqlalchemy import JSON, Column from sqlmodel import Field, SQLModel diff --git a/ergon_core/ergon_core/core/persistence/shared/db.py b/ergon_core/ergon_core/core/persistence/shared/db.py index 854b3361..c4490c37 100644 --- a/ergon_core/ergon_core/core/persistence/shared/db.py +++ b/ergon_core/ergon_core/core/persistence/shared/db.py @@ -10,7 +10,7 @@ from alembic import command from alembic.config import Config -from ergon_core.core.settings import Settings +from ergon_core.core.shared.settings import Settings from sqlalchemy import Engine from sqlmodel import Session, create_engine diff --git a/ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py b/ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py index b58f4ea3..121cc3ea 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py @@ -9,12 +9,12 @@ from pydantic import BaseModel, Field, model_validator -from ergon_core.api.results import CriterionObservation +from ergon_core.api.criterion import CriterionEvidence EvalCriterionStatus = Literal["passed", "failed", "errored", "skipped"] -class CriterionResultEntry(BaseModel): +class CriterionOutcomeEntry(BaseModel): """One criterion result as stored in the evaluation summary.""" criterion_slug: str @@ -36,7 +36,7 @@ class CriterionResultEntry(BaseModel): evaluation_input: str | None = None evaluated_action_ids: list[str] = Field(default_factory=list) evaluated_resource_ids: list[str] = Field(default_factory=list) - observation: CriterionObservation | None = None + observation: CriterionEvidence | None = None error: dict | None = None @model_validator(mode="before") @@ -59,4 +59,4 @@ class EvaluationSummary(BaseModel): stages_passed: int = 0 failed_gate: str | None = None metadata: dict = Field(default_factory=dict) - criterion_results: list[CriterionResultEntry] = Field(default_factory=list) + criterion_results: list[CriterionOutcomeEntry] = Field(default_factory=list) diff --git a/ergon_core/ergon_core/core/persistence/telemetry/models.py b/ergon_core/ergon_core/core/persistence/telemetry/models.py index 54764416..23f8b340 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/models.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/models.py @@ -10,13 +10,13 @@ from uuid import UUID, uuid4 import sqlalchemy as sa -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject from ergon_core.core.persistence.shared.enums import ( RunStatus, TaskExecutionStatus, TrainingStatus, ) -from ergon_core.core.utils import utcnow as _utcnow +from ergon_core.core.shared.utils import utcnow as _utcnow from pydantic import model_validator from sqlalchemy import JSON, Column, DateTime from sqlmodel import Field, SQLModel @@ -65,6 +65,8 @@ class ExperimentRecord(SQLModel, table=True): default_worker_team_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) default_evaluator_slug: str | None = Field(default=None, index=True) default_model_target: str | None = None + sandbox_slug: str | None = Field(default=None, index=True) + dependency_extras_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) design_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) seed: int | None = None metadata_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) @@ -86,6 +88,11 @@ def parsed_default_worker_team(self) -> JsonObject: def parsed_design(self) -> JsonObject: return self.__class__._parse_json_object(self.design_json, "design_json") + def parsed_dependency_extras(self) -> JsonObject: + return self.__class__._parse_json_object( + self.dependency_extras_json, "dependency_extras_json" + ) + def parsed_metadata(self) -> JsonObject: return self.__class__._parse_json_object(self.metadata_json, "metadata_json") @@ -99,6 +106,7 @@ def _parse_json_object(cls, data: dict, field_name: str) -> JsonObject: def _validate_fields(self) -> "ExperimentRecord": self.__class__._parse_json_object(self.sample_selection_json, "sample_selection_json") self.__class__._parse_json_object(self.default_worker_team_json, "default_worker_team_json") + self.__class__._parse_json_object(self.dependency_extras_json, "dependency_extras_json") self.__class__._parse_json_object(self.design_json, "design_json") self.__class__._parse_json_object(self.metadata_json, "metadata_json") return self @@ -124,6 +132,8 @@ class RunRecord(SQLModel, table=True): worker_team_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) evaluator_slug: str | None = Field(default=None, index=True) model_target: str | None = None + sandbox_slug: str | None = Field(default=None, index=True) + dependency_extras_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) assignment_json: dict = Field(default_factory=dict, sa_column=Column(JSON)) seed: int | None = None status: RunStatus = Field(index=True) @@ -139,6 +149,11 @@ def parsed_worker_team(self) -> JsonObject: def parsed_assignment(self) -> JsonObject: return self.__class__._parse_json_object(self.assignment_json, "assignment_json") + def parsed_dependency_extras(self) -> JsonObject: + return self.__class__._parse_json_object( + self.dependency_extras_json, "dependency_extras_json" + ) + def parsed_summary(self) -> JsonObject: return self.__class__._parse_json_object(self.summary_json, "summary_json") @@ -151,6 +166,7 @@ def _parse_json_object(cls, data: dict, field_name: str) -> JsonObject: @model_validator(mode="after") def _validate_fields(self) -> "RunRecord": self.__class__._parse_json_object(self.worker_team_json, "worker_team_json") + self.__class__._parse_json_object(self.dependency_extras_json, "dependency_extras_json") self.__class__._parse_json_object(self.assignment_json, "assignment_json") self.__class__._parse_json_object(self.summary_json, "summary_json") try: diff --git a/ergon_core/ergon_core/core/persistence/telemetry/repositories.py b/ergon_core/ergon_core/core/persistence/telemetry/repositories.py index 3c969e47..ced5e50d 100644 --- a/ergon_core/ergon_core/core/persistence/telemetry/repositories.py +++ b/ergon_core/ergon_core/core/persistence/telemetry/repositories.py @@ -2,12 +2,9 @@ from uuid import UUID -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject from ergon_core.core.persistence.shared.ids import new_id -from ergon_core.core.persistence.telemetry.models import ( - RunRecord, - RunTaskEvaluation, -) +from ergon_core.core.persistence.telemetry.models import RunTaskEvaluation from pydantic import BaseModel from sqlmodel import Session, select @@ -64,22 +61,3 @@ def create_task_evaluation( session.flush() return evaluation - def refresh_run_evaluation_summary(self, session: Session, run_id: UUID) -> None: - run = session.get(RunRecord, run_id) - if run is None: - return - evaluations = self.get_task_evaluations(session, run_id) - scores = [evaluation.score for evaluation in evaluations if evaluation.score is not None] - final_score = sum(scores) if scores else None - normalized_score = final_score / len(scores) if scores and final_score is not None else None - existing_summary = dict({} if run.summary_json is None else run.summary_json) - existing_summary.update( - { - "final_score": final_score, - "normalized_score": normalized_score, - "evaluators_count": len(evaluations), - } - ) - run.summary_json = existing_summary - session.add(run) - session.flush() diff --git a/ergon_core/ergon_core/core/rl/extraction.py b/ergon_core/ergon_core/core/rl/extraction.py index 584b6bea..ba323a7b 100644 --- a/ergon_core/ergon_core/core/rl/extraction.py +++ b/ergon_core/ergon_core/core/rl/extraction.py @@ -14,7 +14,7 @@ from collections import defaultdict from typing import Protocol, runtime_checkable -from ergon_core.core.generation import ( +from ergon_core.core.domain.generation.context_parts import ( AssistantTextPart, ContextPartChunkLog, SystemPromptPart, diff --git a/ergon_core/ergon_core/core/rl/rollout_service.py b/ergon_core/ergon_core/core/rl/rollout_service.py index 4cba6a42..4955c766 100644 --- a/ergon_core/ergon_core/core/rl/rollout_service.py +++ b/ergon_core/ergon_core/core/rl/rollout_service.py @@ -40,7 +40,7 @@ SubmitResponse, Trajectory, ) -from ergon_core.core.runtime.events.task_events import WorkflowStartedEvent +from ergon_core.core.application.events.task_events import WorkflowStartedEvent from sqlmodel import Session, select from transformers import AutoTokenizer diff --git a/ergon_core/migrations/env.py b/ergon_core/migrations/env.py index e4520e89..bb6828e4 100644 --- a/ergon_core/migrations/env.py +++ b/ergon_core/migrations/env.py @@ -11,7 +11,7 @@ import ergon_core.core.persistence.saved_specs.models import ergon_core.core.persistence.telemetry.models from alembic import context -from ergon_core.core.settings import Settings +from ergon_core.core.shared.settings import Settings from sqlalchemy import engine_from_config, pool from sqlmodel import SQLModel diff --git a/ergon_core/migrations/versions/c2d3e4f5a6b7_add_sandbox_dependency_fields.py b/ergon_core/migrations/versions/c2d3e4f5a6b7_add_sandbox_dependency_fields.py new file mode 100644 index 00000000..d3d9e6bc --- /dev/null +++ b/ergon_core/migrations/versions/c2d3e4f5a6b7_add_sandbox_dependency_fields.py @@ -0,0 +1,53 @@ +"""add sandbox and dependency fields to experiments and runs + +Revision ID: c2d3e4f5a6b7 +Revises: b1c2d3e4f5a6 +Create Date: 2026-04-29 00:10:00.000000 +""" + +from typing import Sequence + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +revision: str = "c2d3e4f5a6b7" +down_revision: str | None = "b1c2d3e4f5a6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "experiments", + sa.Column("sandbox_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + ) + op.add_column( + "experiments", + sa.Column("dependency_extras_json", sa.JSON(), nullable=False, server_default="{}"), + ) + op.create_index( + op.f("ix_experiments_sandbox_slug"), + "experiments", + ["sandbox_slug"], + ) + + op.add_column( + "runs", + sa.Column("sandbox_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + ) + op.add_column( + "runs", + sa.Column("dependency_extras_json", sa.JSON(), nullable=False, server_default="{}"), + ) + op.create_index(op.f("ix_runs_sandbox_slug"), "runs", ["sandbox_slug"]) + + +def downgrade() -> None: + op.drop_index(op.f("ix_runs_sandbox_slug"), table_name="runs") + op.drop_column("runs", "dependency_extras_json") + op.drop_column("runs", "sandbox_slug") + + op.drop_index(op.f("ix_experiments_sandbox_slug"), table_name="experiments") + op.drop_column("experiments", "dependency_extras_json") + op.drop_column("experiments", "sandbox_slug") diff --git a/ergon_infra/ergon_infra/training/callback.py b/ergon_infra/ergon_infra/training/callback.py index 28bc1cef..5779a2e4 100644 --- a/ergon_infra/ergon_infra/training/callback.py +++ b/ergon_infra/ergon_infra/training/callback.py @@ -9,7 +9,7 @@ from uuid import UUID from ergon_core.core.persistence.telemetry.models import TrainingMetric, TrainingSession -from ergon_core.core.utils import utcnow +from ergon_core.core.shared.utils import utcnow from sqlmodel import Session from transformers import TrainerCallback, TrainerControl, TrainerState, TrainingArguments diff --git a/pyproject.toml b/pyproject.toml index 1faf9719..5a30e277 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,9 +54,7 @@ max-complexity = 10 # main (13): spike script, not library code "scripts/**" = ["C901"] # persist_definition (28): orchestrates full experiment graph persistence in one method -"ergon_core/ergon_core/core/runtime/services/experiment_persistence_service.py" = ["C901"] -# validate (19): validates multi-level experiment object graph -"ergon_core/ergon_core/api/experiment.py" = ["C901"] +"ergon_core/ergon_core/core/application/experiments/definition_writer.py" = ["C901"] # extract_agent_trajectories (13), _extract_trajectories (11) "ergon_core/ergon_core/core/rl/extraction.py" = ["C901"] "ergon_core/ergon_core/core/rl/rollout_service.py" = ["C901"] @@ -84,10 +82,10 @@ include = ["**/persistence/**/models.py", "**/persistence/graph/**"] [tool.ty.overrides.rules] invalid-argument-type = "warn" -# Inngest step functions: ctx.event.data is typed as JSON, not the unpacked model; +# Inngest handlers: ctx.event.data is typed as JSON, not the unpacked model; # ctx.step.run accepts sync or async but stubs only declare async. [[tool.ty.overrides]] -include = ["**/runtime/inngest/**"] +include = ["**/infrastructure/inngest/**"] [tool.ty.overrides.rules] invalid-argument-type = "warn" @@ -112,7 +110,7 @@ invalid-assignment = "warn" # invalid-assignment: try/except ImportError fallbacks (AsyncSandbox = None, # CommandExitException = Exception) when e2b SDK is unavailable. [[tool.ty.overrides]] -include = ["ergon_core/ergon_core/core/sandbox/**"] +include = ["ergon_core/ergon_core/core/infrastructure/sandbox/**"] [tool.ty.overrides.rules] invalid-argument-type = "warn" unresolved-attribute = "warn" @@ -157,9 +155,7 @@ invalid-return-type = "warn" # invalid-assignment: str passed to TaskExecutionStatus field — SQLModel coerces at runtime. [[tool.ty.overrides]] include = [ - "**/persistence/queries.py", "**/persistence/telemetry/repositories.py", - "**/persistence/context/repository.py", "**/core/rl/rollout_service.py", ] [tool.ty.overrides.rules] @@ -176,16 +172,13 @@ invalid-assignment = "warn" # Remaining files with isolated third-party type issues (inngest JSON, pydantic-ai, SQLModel select). # invalid-assignment covers: -# - tracing.py: OTLPSpanExporter = None try/except ImportError fallback -# - runtime/services/: SQLModel str→enum coercion (status fields) +# - infrastructure tracing: OTLPSpanExporter = None try/except ImportError fallback # - ergon_infra/: tokenizer.pad_token assignment + TrainingStatus str coercion [[tool.ty.overrides]] include = [ - "**/core/api/runs.py", - "**/core/api/test_harness.py", - "**/runtime/tracing.py", - "**/runtime/services/**", - "**/runtime/execution/**", + "**/core/rest_api/runs.py", + "**/core/rest_api/test_harness.py", + "**/core/infrastructure/tracing/**", "**/providers/judges/**", "**/benchmarks/smoke_test/**", "ergon_cli/**", @@ -201,14 +194,14 @@ invalid-assignment = "warn" # Worker ABC: abstract async generator yield + Unknown response_text. [[tool.ty.overrides]] -include = ["ergon_core/ergon_core/api/worker.py"] +include = ["ergon_core/ergon_core/api/worker/worker.py"] [tool.ty.overrides.rules] invalid-yield = "warn" invalid-argument-type = "warn" # inngest_client.send_sync signature vs Callable[[Event], None] — harmless mismatch. [[tool.ty.overrides]] -include = ["**/core/api/app.py"] +include = ["**/core/rest_api/app.py"] [tool.ty.overrides.rules] invalid-argument-type = "warn" @@ -253,6 +246,7 @@ allowed-unresolved-imports = [ [tool.pytest.ini_options] asyncio_mode = "auto" +pythonpath = ["."] testpaths = ["tests"] timeout = 600 markers = [ From 0133cd6b46e2d09d23cf33a096088c376c40b7e7 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:46 +0100 Subject: [PATCH 61/66] refactor: extract builtin worker factories and shared helpers Made-with: Cursor --- .../benchmarks/gdpeval/worker_factory.py | 38 +++++++++++ .../benchmarks/minif2f/worker_factory.py | 63 +++++++++++++++++++ .../researchrubrics/worker_factory.py | 8 +++ .../swebench_verified/worker_factory.py | 37 +++++++++++ .../ergon_builtins/shared/__init__.py | 1 + .../shared/criteria/__init__.py | 7 +++ .../shared/criteria/code_check.py | 3 + .../shared/criteria/llm_judge.py | 3 + .../shared/criteria/sandbox_file_check.py | 3 + .../ergon_builtins/shared/models/__init__.py | 5 ++ .../shared/models/resolution.py | 3 + .../ergon_builtins/shared/workers/__init__.py | 6 ++ .../shared/workers/react_prompts.py | 6 ++ .../shared/workers/react_worker.py | 3 + .../shared/workers/training_stub_worker.py | 3 + .../workers/baselines/training_stub_worker.py | 12 ++-- 16 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 ergon_builtins/ergon_builtins/benchmarks/gdpeval/worker_factory.py create mode 100644 ergon_builtins/ergon_builtins/benchmarks/minif2f/worker_factory.py create mode 100644 ergon_builtins/ergon_builtins/benchmarks/researchrubrics/worker_factory.py create mode 100644 ergon_builtins/ergon_builtins/benchmarks/swebench_verified/worker_factory.py create mode 100644 ergon_builtins/ergon_builtins/shared/__init__.py create mode 100644 ergon_builtins/ergon_builtins/shared/criteria/__init__.py create mode 100644 ergon_builtins/ergon_builtins/shared/criteria/code_check.py create mode 100644 ergon_builtins/ergon_builtins/shared/criteria/llm_judge.py create mode 100644 ergon_builtins/ergon_builtins/shared/criteria/sandbox_file_check.py create mode 100644 ergon_builtins/ergon_builtins/shared/models/__init__.py create mode 100644 ergon_builtins/ergon_builtins/shared/models/resolution.py create mode 100644 ergon_builtins/ergon_builtins/shared/workers/__init__.py create mode 100644 ergon_builtins/ergon_builtins/shared/workers/react_prompts.py create mode 100644 ergon_builtins/ergon_builtins/shared/workers/react_worker.py create mode 100644 ergon_builtins/ergon_builtins/shared/workers/training_stub_worker.py diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/worker_factory.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/worker_factory.py new file mode 100644 index 00000000..fa9cf9e9 --- /dev/null +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/worker_factory.py @@ -0,0 +1,38 @@ +"""GDPEval worker factories.""" + +from uuid import UUID + +from ergon_builtins.benchmarks.gdpeval.sandbox import GDPEvalSandboxManager +from ergon_builtins.benchmarks.gdpeval.toolkit import GDPEvalToolkit +from ergon_builtins.shared.workers.react_worker import ReActWorker + +GDPEVAL_SYSTEM_PROMPT = """You are a GDPEval document-processing agent. + +Use the provided tools to inspect input documents, transform data, run Python +when useful, and write final artifacts under /workspace/final_output. Keep a +short final answer that names the produced files and any assumptions. +""" + + +def gdpeval_react( + *, + name: str, + model: str | None, + task_id: UUID, + sandbox_id: str, +) -> ReActWorker: + """Registry factory: ReActWorker wired with the GDPEval document toolkit.""" + toolkit = GDPEvalToolkit( + task_id=task_id, + run_id=task_id, + sandbox_manager=GDPEvalSandboxManager(), + ) + return ReActWorker( + name=name, + model=model, + task_id=task_id, + sandbox_id=sandbox_id, + tools=list(toolkit.get_tools()), + system_prompt=GDPEVAL_SYSTEM_PROMPT, + max_iterations=40, + ) diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/worker_factory.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/worker_factory.py new file mode 100644 index 00000000..68af93dc --- /dev/null +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/worker_factory.py @@ -0,0 +1,63 @@ +"""MiniF2F worker factories.""" + +from typing import Any +from uuid import UUID + +from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager +from ergon_builtins.benchmarks.minif2f.toolkit import MiniF2FToolkit +from ergon_builtins.shared.workers.react_prompts import MINIF2F_SYSTEM_PROMPT +from ergon_builtins.shared.workers.react_worker import ReActWorker + + +def _minif2f_run_skill(sandbox: Any) -> Any: # slopcop: ignore[no-typing-any] + """Return the ``write_lean_file`` run_skill callback bound to ``sandbox``.""" + + async def run_skill( + _run_id: UUID, + skill_name: str, + response_model: type, + **kwargs: Any, # slopcop: ignore[no-typing-any] + ) -> Any: # slopcop: ignore[no-typing-any] + if skill_name != "write_lean_file": + raise ValueError(f"MiniF2F factory does not support skill {skill_name!r}") + file_path = kwargs["file_path"] + content = kwargs["content"] + payload = content.encode("utf-8") if isinstance(content, str) else content + await sandbox.files.write(file_path, payload) + return response_model( + success=True, + filename=file_path, + bytes_written=len(payload), + ) + + return run_skill + + +def minif2f_react( + *, + name: str, + model: str | None, + task_id: UUID, + sandbox_id: str, +) -> ReActWorker: + """Registry factory: ReActWorker wired with a live MiniF2F toolkit.""" + sandbox = MiniF2FSandboxManager().get_sandbox(task_id) + if sandbox is None: + raise RuntimeError( + f"MiniF2F factory requires a live sandbox for task_id={task_id}; " + "SandboxSetupRequest must have completed before worker-execute runs." + ) + toolkit = MiniF2FToolkit( + sandbox=sandbox, + sandbox_run_skill=_minif2f_run_skill(sandbox), + run_id=task_id, + ) + return ReActWorker( + name=name, + model=model, + task_id=task_id, + sandbox_id=sandbox_id, + tools=list(toolkit.get_tools()), + system_prompt=MINIF2F_SYSTEM_PROMPT, + max_iterations=30, + ) diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/worker_factory.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/worker_factory.py new file mode 100644 index 00000000..2f9ca14f --- /dev/null +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/worker_factory.py @@ -0,0 +1,8 @@ +"""ResearchRubrics worker registry exports.""" + +from ergon_builtins.workers.research_rubrics.researcher_worker import ( + ResearchRubricsResearcherWorker, +) +from ergon_builtins.workers.research_rubrics.workflow_cli_react_worker import ( + ResearchRubricsWorkflowCliReActWorker, +) diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/worker_factory.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/worker_factory.py new file mode 100644 index 00000000..1df9d7b8 --- /dev/null +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/worker_factory.py @@ -0,0 +1,37 @@ +"""SWE-Bench Verified worker factories.""" + +from uuid import UUID + +from ergon_builtins.benchmarks.swebench_verified.sandbox_manager import ( + SWEBenchSandboxManager, +) +from ergon_builtins.benchmarks.swebench_verified.toolkit import SWEBenchToolkit +from ergon_builtins.shared.workers.react_prompts import SWEBENCH_SYSTEM_PROMPT +from ergon_builtins.shared.workers.react_worker import ReActWorker + + +def swebench_react( + *, + name: str, + model: str | None, + task_id: UUID, + sandbox_id: str, +) -> ReActWorker: + """Registry factory: ReActWorker wired with a live SWE-Bench toolkit.""" + sandbox = SWEBenchSandboxManager().get_sandbox(task_id) + if sandbox is None: + raise RuntimeError( + f"SWE-Bench factory requires a live sandbox for task_id={task_id}; " + "SandboxSetupRequest must have completed (including " + "_install_dependencies) before worker-execute runs." + ) + toolkit = SWEBenchToolkit(sandbox=sandbox, workdir="/workspace/repo") + return ReActWorker( + name=name, + model=model, + task_id=task_id, + sandbox_id=sandbox_id, + tools=list(toolkit.get_tools()), + system_prompt=SWEBENCH_SYSTEM_PROMPT, + max_iterations=50, + ) diff --git a/ergon_builtins/ergon_builtins/shared/__init__.py b/ergon_builtins/ergon_builtins/shared/__init__.py new file mode 100644 index 00000000..82abdd8d --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/__init__.py @@ -0,0 +1 @@ +"""Shared built-in primitives for benchmark packages.""" diff --git a/ergon_builtins/ergon_builtins/shared/criteria/__init__.py b/ergon_builtins/ergon_builtins/shared/criteria/__init__.py new file mode 100644 index 00000000..11bdeae8 --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/criteria/__init__.py @@ -0,0 +1,7 @@ +"""Shared criterion implementations.""" + +from ergon_builtins.shared.criteria.code_check import CodeCheckCriterion +from ergon_builtins.shared.criteria.llm_judge import LLMJudgeCriterion +from ergon_builtins.shared.criteria.sandbox_file_check import SandboxFileCheckCriterion + +__all__ = ["CodeCheckCriterion", "LLMJudgeCriterion", "SandboxFileCheckCriterion"] diff --git a/ergon_builtins/ergon_builtins/shared/criteria/code_check.py b/ergon_builtins/ergon_builtins/shared/criteria/code_check.py new file mode 100644 index 00000000..7d86d5f9 --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/criteria/code_check.py @@ -0,0 +1,3 @@ +"""Shared code-check criterion import surface.""" + +from ergon_builtins.evaluators.criteria.code_check import CodeCheckCriterion diff --git a/ergon_builtins/ergon_builtins/shared/criteria/llm_judge.py b/ergon_builtins/ergon_builtins/shared/criteria/llm_judge.py new file mode 100644 index 00000000..aae25510 --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/criteria/llm_judge.py @@ -0,0 +1,3 @@ +"""Shared LLM-judge criterion import surface.""" + +from ergon_builtins.evaluators.criteria.llm_judge import LLMJudgeCriterion diff --git a/ergon_builtins/ergon_builtins/shared/criteria/sandbox_file_check.py b/ergon_builtins/ergon_builtins/shared/criteria/sandbox_file_check.py new file mode 100644 index 00000000..d8986078 --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/criteria/sandbox_file_check.py @@ -0,0 +1,3 @@ +"""Shared sandbox file-check criterion import surface.""" + +from ergon_builtins.evaluators.criteria.sandbox_file_check import SandboxFileCheckCriterion diff --git a/ergon_builtins/ergon_builtins/shared/models/__init__.py b/ergon_builtins/ergon_builtins/shared/models/__init__.py new file mode 100644 index 00000000..dfdf4977 --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/models/__init__.py @@ -0,0 +1,5 @@ +"""Shared model backend import surfaces.""" + +from ergon_builtins.shared.models.resolution import ResolvedModel, resolve_model_target + +__all__ = ["ResolvedModel", "resolve_model_target"] diff --git a/ergon_builtins/ergon_builtins/shared/models/resolution.py b/ergon_builtins/ergon_builtins/shared/models/resolution.py new file mode 100644 index 00000000..a95cf6de --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/models/resolution.py @@ -0,0 +1,3 @@ +"""Shared model resolution import surface.""" + +from ergon_builtins.models.resolution import ResolvedModel, resolve_model_target diff --git a/ergon_builtins/ergon_builtins/shared/workers/__init__.py b/ergon_builtins/ergon_builtins/shared/workers/__init__.py new file mode 100644 index 00000000..59783a10 --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/workers/__init__.py @@ -0,0 +1,6 @@ +"""Shared worker implementations.""" + +from ergon_builtins.shared.workers.react_worker import ReActWorker +from ergon_builtins.shared.workers.training_stub_worker import TrainingStubWorker + +__all__ = ["ReActWorker", "TrainingStubWorker"] diff --git a/ergon_builtins/ergon_builtins/shared/workers/react_prompts.py b/ergon_builtins/ergon_builtins/shared/workers/react_prompts.py new file mode 100644 index 00000000..18f44623 --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/workers/react_prompts.py @@ -0,0 +1,6 @@ +"""Shared ReAct prompt constants.""" + +from ergon_builtins.workers.baselines.react_prompts import ( + MINIF2F_SYSTEM_PROMPT, + SWEBENCH_SYSTEM_PROMPT, +) diff --git a/ergon_builtins/ergon_builtins/shared/workers/react_worker.py b/ergon_builtins/ergon_builtins/shared/workers/react_worker.py new file mode 100644 index 00000000..d04dd3a4 --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/workers/react_worker.py @@ -0,0 +1,3 @@ +"""Shared ReAct worker import surface.""" + +from ergon_builtins.workers.baselines.react_worker import ReActWorker diff --git a/ergon_builtins/ergon_builtins/shared/workers/training_stub_worker.py b/ergon_builtins/ergon_builtins/shared/workers/training_stub_worker.py new file mode 100644 index 00000000..2b5f7766 --- /dev/null +++ b/ergon_builtins/ergon_builtins/shared/workers/training_stub_worker.py @@ -0,0 +1,3 @@ +"""Shared training stub worker import surface.""" + +from ergon_builtins.workers.baselines.training_stub_worker import TrainingStubWorker diff --git a/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py b/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py index 44e21b14..126a674d 100644 --- a/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py +++ b/ergon_builtins/ergon_builtins/workers/baselines/training_stub_worker.py @@ -13,8 +13,8 @@ from collections.abc import AsyncGenerator from uuid import UUID -from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.core.generation import ( +from ergon_core.api import Task, Worker, WorkerContext, WorkerOutput, WorkerStreamItem +from ergon_core.core.domain.generation.context_parts import ( AssistantTextPart, ContextPartChunk, TokenLogprob, @@ -39,12 +39,16 @@ def __init__( async def execute( self, - task: BenchmarkTask, + task: Task, *, context: WorkerContext, - ) -> AsyncGenerator[ContextPartChunk, None]: + ) -> AsyncGenerator[WorkerStreamItem, None]: + output = "" for chunk in _build_synthetic_chunks(task.task_slug): + if isinstance(chunk.part, AssistantTextPart): + output = chunk.part.content yield chunk + yield WorkerOutput(output=output, success=True) def _build_synthetic_chunks(task_slug: str) -> list[ContextPartChunk]: From 5369bb94ba4e527819e7e0b70503aaa0c042bb46 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:46 +0100 Subject: [PATCH 62/66] refactor: register builtins through explicit registry hooks Made-with: Cursor --- ergon_builtins/ergon_builtins/registry.py | 52 ++++-- .../ergon_builtins/registry_core.py | 153 ++++-------------- .../ergon_builtins/registry_data.py | 25 ++- .../ergon_builtins/registry_local_models.py | 9 +- 4 files changed, 101 insertions(+), 138 deletions(-) diff --git a/ergon_builtins/ergon_builtins/registry.py b/ergon_builtins/ergon_builtins/registry.py index b6453ce2..d45073b6 100644 --- a/ergon_builtins/ergon_builtins/registry.py +++ b/ergon_builtins/ergon_builtins/registry.py @@ -1,18 +1,19 @@ -"""Composed registry: merges sub-registries based on installed capabilities. +"""Register built-in Ergon components into the core public registry. -No decorators, no scanning. Sub-registries use eager, fully-typed imports. +No decorators, no scanning. Sub-registries use eager, fully typed imports. The only conditionality is at this composition boundary. """ from collections.abc import Callable import structlog -from ergon_core.api import Benchmark, Evaluator, Worker -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.api import Benchmark, Worker +from ergon_core.api.registry import ComponentRegistry, registry +from ergon_core.api.rubric import Evaluator +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager from ergon_builtins.models.resolution import ( ResolvedModel, - register_model_backend, ) from ergon_builtins.registry_core import ( BENCHMARKS as _core_benchmarks, @@ -32,10 +33,44 @@ from ergon_builtins.registry_core import ( WORKERS as _core_workers, ) +from ergon_builtins.registry_core import register_core_builtins log = structlog.get_logger() -# -- Start from core (always available) ------------------------------------ +# -- Explicit registration -------------------------------------------------- + + +def register_builtins(target: ComponentRegistry = registry) -> None: + """Register builtins available in the current environment.""" + + register_core_builtins(target) + _register_local_model_builtins() + _register_data_builtins(target) + + +def _register_local_model_builtins() -> None: + try: + from ergon_builtins.registry_local_models import register_local_model_builtins + except ImportError: + log.info("ergon-builtins[local-models] not installed; local transformers inference unavailable") + return + + register_local_model_builtins() + + +def _register_data_builtins(target: ComponentRegistry) -> None: + try: + from ergon_builtins.registry_data import register_data_builtins + except ImportError: + log.info( + "ergon-builtins[data] not installed; gdpeval and researchrubrics benchmarks unavailable" + ) + return + + register_data_builtins(target) + + +# -- Backwards-compatible snapshots ---------------------------------------- WORKERS: dict[str, Callable[..., Worker]] = {**_core_workers} BENCHMARKS: dict[str, type[Benchmark]] = {**_core_benchmarks} @@ -80,10 +115,7 @@ "ergon-builtins[data] not installed; gdpeval and researchrubrics benchmarks unavailable" ) -# -- Register model backends ----------------------------------------------- - -for prefix, resolver in _model_backends.items(): - register_model_backend(prefix, resolver) +MODEL_BACKENDS: dict[str, Callable[..., ResolvedModel]] = dict(_model_backends) # -- Install hints for slugs that require optional capabilities ------------- diff --git a/ergon_builtins/ergon_builtins/registry_core.py b/ergon_builtins/ergon_builtins/registry_core.py index 1068c7e0..122ea031 100644 --- a/ergon_builtins/ergon_builtins/registry_core.py +++ b/ergon_builtins/ergon_builtins/registry_core.py @@ -6,35 +6,30 @@ from collections.abc import Callable from pathlib import Path -from typing import Any -from uuid import UUID -from ergon_core.api import Benchmark, Evaluator, Worker -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.api import Benchmark, Worker +from ergon_core.api.registry import ComponentRegistry, registry +from ergon_core.api.rubric import Evaluator +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.gdpeval.rubric import StagedRubric from ergon_builtins.benchmarks.gdpeval.sandbox import GDPEvalSandboxManager from ergon_builtins.benchmarks.minif2f.benchmark import MiniF2FBenchmark from ergon_builtins.benchmarks.minif2f.rubric import MiniF2FRubric from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager -from ergon_builtins.benchmarks.minif2f.toolkit import MiniF2FToolkit +from ergon_builtins.benchmarks.minif2f.worker_factory import minif2f_react from ergon_builtins.benchmarks.swebench_verified.benchmark import SweBenchVerifiedBenchmark from ergon_builtins.benchmarks.swebench_verified.sandbox_manager import ( SWEBenchSandboxManager, ) -from ergon_builtins.benchmarks.swebench_verified.toolkit import SWEBenchToolkit -from ergon_builtins.evaluators.rubrics.swebench_rubric import SWEBenchRubric +from ergon_builtins.benchmarks.swebench_verified.rubric import SWEBenchRubric +from ergon_builtins.benchmarks.swebench_verified.worker_factory import swebench_react from ergon_builtins.models.cloud_passthrough import resolve_cloud from ergon_builtins.models.openrouter_backend import resolve_openrouter from ergon_builtins.models.openrouter_responses_backend import resolve_openrouter_responses -from ergon_builtins.models.resolution import ResolvedModel +from ergon_builtins.models.resolution import ResolvedModel, register_model_backend from ergon_builtins.models.vllm_backend import resolve_vllm -from ergon_builtins.workers.baselines.react_prompts import ( - MINIF2F_SYSTEM_PROMPT, - SWEBENCH_SYSTEM_PROMPT, -) -from ergon_builtins.workers.baselines.react_worker import ReActWorker -from ergon_builtins.workers.baselines.training_stub_worker import TrainingStubWorker +from ergon_builtins.shared.workers.training_stub_worker import TrainingStubWorker # reason: Worker factory signature — every registry entry accepts the same # four keyword-only args. Plain ``Worker`` subclasses get them via @@ -43,111 +38,12 @@ WorkerFactory = Callable[..., Worker] -def _minif2f_run_skill(sandbox: Any) -> Any: # slopcop: ignore[no-typing-any] - """Return the ``write_lean_file`` run_skill callback bound to ``sandbox``. - - Extracted from the old ``MiniF2FAdapter`` verbatim. The MiniF2F toolkit - only routes ``write_lean_file`` through this callback; the other tools - drive ``sandbox.commands.run`` directly. - """ - - async def run_skill( - _run_id: UUID, - skill_name: str, - response_model: type, - **kwargs: Any, # slopcop: ignore[no-typing-any] - ) -> Any: # slopcop: ignore[no-typing-any] - if skill_name != "write_lean_file": - raise ValueError(f"MiniF2F factory does not support skill {skill_name!r}") - file_path = kwargs["file_path"] - content = kwargs["content"] - payload = content.encode("utf-8") if isinstance(content, str) else content - await sandbox.files.write(file_path, payload) - return response_model( - success=True, - filename=file_path, - bytes_written=len(payload), - ) - - return run_skill - - -def _minif2f_react( - *, - name: str, - model: str | None, - task_id: UUID, - sandbox_id: str, -) -> ReActWorker: - """Registry factory: ReActWorker wired with a live MiniF2F toolkit.""" - sandbox = MiniF2FSandboxManager().get_sandbox(task_id) - if sandbox is None: - raise RuntimeError( - f"MiniF2F factory requires a live sandbox for task_id={task_id}; " - "SandboxSetupRequest must have completed before worker-execute runs." - ) - toolkit = MiniF2FToolkit( - sandbox=sandbox, - sandbox_run_skill=_minif2f_run_skill(sandbox), - run_id=task_id, - ) - # reason: RFC 2026-04-22 §1 — forward task_id / sandbox_id so the base - # ``Worker.__init__`` invariant is satisfied; ReActWorker passes them - # through to super(). - return ReActWorker( - name=name, - model=model, - task_id=task_id, - sandbox_id=sandbox_id, - tools=list(toolkit.get_tools()), - system_prompt=MINIF2F_SYSTEM_PROMPT, - max_iterations=30, - ) - - -def _swebench_react( - *, - name: str, - model: str | None, - task_id: UUID, - sandbox_id: str, -) -> ReActWorker: - """Registry factory: ReActWorker wired with a live SWE-Bench toolkit.""" - sandbox = SWEBenchSandboxManager().get_sandbox(task_id) - if sandbox is None: - raise RuntimeError( - f"SWE-Bench factory requires a live sandbox for task_id={task_id}; " - "SandboxSetupRequest must have completed (including " - "_install_dependencies) before worker-execute runs." - ) - toolkit = SWEBenchToolkit(sandbox=sandbox, workdir="/workspace/repo") - # reason: RFC 2026-04-22 §1 — forward task_id / sandbox_id so the base - # ``Worker.__init__`` invariant is satisfied. - return ReActWorker( - name=name, - model=model, - task_id=task_id, - sandbox_id=sandbox_id, - tools=list(toolkit.get_tools()), - system_prompt=SWEBENCH_SYSTEM_PROMPT, - max_iterations=50, - ) - - -# Registry maps worker slug → a factory callable accepting -# ``(name=..., model=..., task_id=..., sandbox_id=...)`` that returns a -# ready-to-run Worker. Plain subclasses are referenced directly now that -# base ``Worker.__init__`` requires ``task_id`` and ``sandbox_id``; benchmark -# factories (``_minif2f_react``, ``_swebench_react``) close over their -# sandbox manager and pre-bind a concrete toolkit + system prompt + -# iteration budget. RFC 2026-04-22 §1 + Open Question 1 resolution (c) -# (make IDs required on base Worker, drop ``_plain`` shim). + WORKERS: dict[str, WorkerFactory] = { "training-stub": TrainingStubWorker, - # NOTE: bare `"react-v1": ReActWorker` entry removed (RFC 2026-04-22 §1). - # Every real use binds a concrete toolkit via a factory closure below. - "minif2f-react": _minif2f_react, - "swebench-react": _swebench_react, + + "minif2f-react": minif2f_react, + "swebench-react": swebench_react, # Test-only smoke workers register via tests/e2e/_fixtures/__init__.py; # they do NOT appear here (production CLI paths don't import tests). } @@ -155,18 +51,14 @@ def _swebench_react( BENCHMARKS: dict[str, type[Benchmark]] = { "minif2f": MiniF2FBenchmark, "swebench-verified": SweBenchVerifiedBenchmark, - # ``researchrubrics-smoke`` / ``smoke-test`` benchmarks retired alongside - # the canonical-smoke refactor (see - # docs/architecture/07_testing.md §canonical-smoke). Smoke uses each - # benchmark's real sandbox image via the test-fixture registrations. + } EVALUATORS: dict[str, type[Evaluator]] = { "staged-rubric": StagedRubric, + "gdpeval-staged-rubric": StagedRubric, "minif2f-rubric": MiniF2FRubric, "swebench-rubric": SWEBenchRubric, - # Stub rubrics + smoke rubrics retired. Test-only smoke criteria - # register via tests/e2e/_fixtures/__init__.py. } SANDBOX_MANAGERS: dict[str, type[BaseSandboxManager]] = { @@ -188,3 +80,18 @@ def _swebench_react( "openrouter": resolve_openrouter, "openai-responses": resolve_openrouter_responses, } + + +def register_core_builtins(target: ComponentRegistry = registry) -> None: + """Register builtins that are safe without optional dependency extras.""" + + for slug, worker_factory in WORKERS.items(): + target.register_worker(slug, worker_factory) + for benchmark_cls in BENCHMARKS.values(): + target.register_benchmark(benchmark_cls) + for slug, evaluator_cls in EVALUATORS.items(): + target.register_evaluator(evaluator_cls, slug=slug) + for slug, manager_cls in SANDBOX_MANAGERS.items(): + target.register_sandbox_manager(slug, manager_cls) + for prefix, resolver in MODEL_BACKENDS.items(): + register_model_backend(prefix, resolver) diff --git a/ergon_builtins/ergon_builtins/registry_data.py b/ergon_builtins/ergon_builtins/registry_data.py index 4f75facf..00b9310f 100644 --- a/ergon_builtins/ergon_builtins/registry_data.py +++ b/ergon_builtins/ergon_builtins/registry_data.py @@ -5,10 +5,13 @@ from collections.abc import Callable -from ergon_core.api import Benchmark, Evaluator, Worker -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.api import Benchmark, Worker +from ergon_core.api.registry import ComponentRegistry, registry +from ergon_core.api.rubric import Evaluator +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.gdpeval.benchmark import GDPEvalBenchmark +from ergon_builtins.benchmarks.gdpeval.worker_factory import gdpeval_react from ergon_builtins.benchmarks.researchrubrics.benchmark import ResearchRubricsBenchmark from ergon_builtins.benchmarks.researchrubrics.rubric import ResearchRubricsRubric from ergon_builtins.benchmarks.researchrubrics.sandbox_manager import ( @@ -17,10 +20,10 @@ from ergon_builtins.benchmarks.researchrubrics.vanilla import ( ResearchRubricsVanillaBenchmark, ) -from ergon_builtins.workers.research_rubrics.researcher_worker import ( +from ergon_builtins.benchmarks.researchrubrics.worker_factory import ( ResearchRubricsResearcherWorker, ) -from ergon_builtins.workers.research_rubrics.workflow_cli_react_worker import ( +from ergon_builtins.benchmarks.researchrubrics.worker_factory import ( ResearchRubricsWorkflowCliReActWorker, ) @@ -41,6 +44,7 @@ # stores the bare class (``WorkerFactory = Callable[..., Worker]``) and # ``_plain`` has been deleted. WORKERS: dict[str, Callable[..., Worker]] = { + "gdpeval-react": gdpeval_react, "researchrubrics-researcher": ResearchRubricsResearcherWorker, "researchrubrics-workflow-cli-react": ResearchRubricsWorkflowCliReActWorker, } @@ -49,3 +53,16 @@ "researchrubrics": ResearchRubricsSandboxManager, "researchrubrics-vanilla": ResearchRubricsSandboxManager, } + + +def register_data_builtins(target: ComponentRegistry = registry) -> None: + """Register builtins that require the [data] optional dependency group.""" + + for benchmark_cls in BENCHMARKS.values(): + target.register_benchmark(benchmark_cls) + for slug, evaluator_cls in EVALUATORS.items(): + target.register_evaluator(evaluator_cls, slug=slug) + for slug, worker_factory in WORKERS.items(): + target.register_worker(slug, worker_factory) + for slug, manager_cls in SANDBOX_MANAGERS.items(): + target.register_sandbox_manager(slug, manager_cls) diff --git a/ergon_builtins/ergon_builtins/registry_local_models.py b/ergon_builtins/ergon_builtins/registry_local_models.py index 5632f688..e750aa23 100644 --- a/ergon_builtins/ergon_builtins/registry_local_models.py +++ b/ergon_builtins/ergon_builtins/registry_local_models.py @@ -7,9 +7,16 @@ from collections.abc import Callable -from ergon_builtins.models.resolution import ResolvedModel +from ergon_builtins.models.resolution import ResolvedModel, register_model_backend from ergon_builtins.models.transformers_backend import resolve_transformers MODEL_BACKENDS: dict[str, Callable[..., ResolvedModel]] = { "transformers": resolve_transformers, } + + +def register_local_model_builtins() -> None: + """Register model backends that require local-model optional dependencies.""" + + for prefix, resolver in MODEL_BACKENDS.items(): + register_model_backend(prefix, resolver) From 57dea6b07907f5beef8286885cc7c5175001b5ca Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:46 +0100 Subject: [PATCH 63/66] refactor: update builtin benchmarks and evaluators for new APIs Made-with: Cursor --- .../benchmarks/gdpeval/benchmark.py | 12 ++- .../benchmarks/gdpeval/rubric.py | 12 +-- .../benchmarks/gdpeval/sandbox.py | 2 +- .../benchmarks/gdpeval/sandbox_utils.py | 2 +- .../benchmarks/gdpeval/toolkit.py | 2 +- .../benchmarks/minif2f/benchmark.py | 12 ++- .../benchmarks/minif2f/rubric.py | 10 +-- .../minif2f/rules/proof_verification.py | 29 +++--- .../benchmarks/minif2f/sandbox_manager.py | 2 +- .../benchmarks/researchrubrics/benchmark.py | 14 ++- .../researchrubrics/judge_criterion.py | 41 ++++----- .../benchmarks/researchrubrics/rubric.py | 12 +-- .../researchrubrics/sandbox_manager.py | 4 +- .../benchmarks/swebench_verified/benchmark.py | 12 ++- .../benchmarks/swebench_verified/criterion.py | 52 +++++------ .../benchmarks/swebench_verified/rubric.py | 21 +++++ .../swebench_verified/sandbox_manager.py | 18 ++-- .../common/llm_context/adapters/base.py | 2 +- .../llm_context/adapters/pydantic_ai.py | 2 +- .../evaluators/criteria/code_check.py | 10 +-- .../evaluators/criteria/llm_judge.py | 10 +-- .../evaluators/criteria/sandbox_file_check.py | 24 ++--- .../evaluators/rubrics/swebench_rubric.py | 22 +---- .../models/openrouter_backend.py | 2 +- .../models/openrouter_responses_backend.py | 2 +- .../ergon_builtins/models/resolution.py | 2 +- .../ergon_builtins/tools/graph_toolkit.py | 40 ++++++--- .../tools/graph_toolkit_types.py | 3 +- .../tools/subtask_lifecycle_toolkit.py | 8 +- .../ergon_builtins/tools/workflow_cli_tool.py | 4 +- .../workers/baselines/react_worker.py | 89 +++++++------------ .../research_rubrics/researcher_worker.py | 10 +-- .../workflow_cli_react_worker.py | 10 +-- 33 files changed, 228 insertions(+), 269 deletions(-) create mode 100644 ergon_builtins/ergon_builtins/benchmarks/swebench_verified/rubric.py diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/benchmark.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/benchmark.py index 3468f100..a634f406 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/benchmark.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/benchmark.py @@ -8,9 +8,7 @@ from collections.abc import Mapping, Sequence from typing import ClassVar -from ergon_core.api.benchmark import Benchmark -from ergon_core.api.benchmark_deps import BenchmarkDeps -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.benchmark import Benchmark, BenchmarkRequirements, Task from ergon_builtins.benchmarks.gdpeval.loader import ( HF_REPO_ID, @@ -34,7 +32,7 @@ class GDPEvalBenchmark(Benchmark): type_slug: ClassVar[str] = "gdpeval" task_payload_model: ClassVar[type[GDPTaskConfig]] = GDPTaskConfig - onboarding_deps: ClassVar[BenchmarkDeps] = BenchmarkDeps( + onboarding_deps: ClassVar[BenchmarkRequirements] = BenchmarkRequirements( e2b=True, extras=("ergon-builtins[data]",), ) @@ -56,17 +54,17 @@ def __init__( self.split = split self.limit = limit - def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[GDPTaskConfig]]]: + def build_instances(self) -> Mapping[str, Sequence[Task[GDPTaskConfig]]]: """Materialise one ``BenchmarkTask`` per GDP task. All tasks land in a single ``"default"`` instance since there is no multi-instance structure in the GDP dataset. """ - tasks: list[BenchmarkTask[GDPTaskConfig]] = [] + tasks: list[Task[GDPTaskConfig]] = [] for payload in self._load_task_configs(): description = extract_task_description(payload.task_id, repo_id=self.dataset_repo) tasks.append( - BenchmarkTask[GDPTaskConfig]( + Task[GDPTaskConfig]( task_slug=payload.task_id, instance_key="default", description=description, diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/rubric.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/rubric.py index 64a661a4..72f1d5ec 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/rubric.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/rubric.py @@ -12,9 +12,9 @@ from typing import ClassVar, Literal from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluator import Rubric -from ergon_core.api.results import CriterionResult, TaskEvaluationResult -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.benchmark import Task +from ergon_core.api.criterion import CriterionOutcome +from ergon_core.api.rubric import Rubric, TaskEvaluationResult from pydantic import BaseModel, Field, model_validator logger = logging.getLogger(__name__) @@ -146,8 +146,8 @@ def __init__( def aggregate_task( self, - task: BenchmarkTask, - criterion_results: Iterable[CriterionResult], + task: Task, + criterion_results: Iterable[CriterionOutcome], ) -> TaskEvaluationResult: results = list(criterion_results) stage_results = self._rebuild_stage_results(results) @@ -212,7 +212,7 @@ def validate(self) -> None: # -- internal helpers --------------------------------------------------- - def _rebuild_stage_results(self, criterion_results: list[CriterionResult]) -> list[dict]: + def _rebuild_stage_results(self, criterion_results: list[CriterionOutcome]) -> list[dict]: stage_results: list[dict] = [] for stage_idx, stage in enumerate(self.stages): stage_criteria = [ diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox.py index fbcd0bdf..411146a9 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox.py @@ -7,7 +7,7 @@ import logging from uuid import UUID -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager try: from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox_utils.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox_utils.py index e7b20fd4..3f13a894 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox_utils.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/sandbox_utils.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, Field if TYPE_CHECKING: - from ergon_core.core.sandbox.manager import BaseSandboxManager + from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager logger = logging.getLogger(__name__) diff --git a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/toolkit.py b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/toolkit.py index a7346f99..44d3a31a 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/gdpeval/toolkit.py +++ b/ergon_builtins/ergon_builtins/benchmarks/gdpeval/toolkit.py @@ -25,7 +25,7 @@ ) if TYPE_CHECKING: - from ergon_core.core.sandbox.manager import BaseSandboxManager + from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager class QAExchange: diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/benchmark.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/benchmark.py index bf7fa1eb..299ec93a 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/benchmark.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/benchmark.py @@ -11,9 +11,7 @@ from pathlib import Path from typing import Any, ClassVar -from ergon_core.api.benchmark import Benchmark -from ergon_core.api.benchmark_deps import BenchmarkDeps -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.benchmark import Benchmark, BenchmarkRequirements, Task from huggingface_hub import hf_hub_download from ergon_builtins.benchmarks.minif2f.task_schemas import MiniF2FProblem, MiniF2FTaskPayload @@ -33,7 +31,7 @@ class MiniF2FBenchmark(Benchmark): type_slug: ClassVar[str] = "minif2f" task_payload_model: ClassVar[type[MiniF2FTaskPayload]] = MiniF2FTaskPayload - onboarding_deps: ClassVar[BenchmarkDeps] = BenchmarkDeps(e2b=True) + onboarding_deps: ClassVar[BenchmarkRequirements] = BenchmarkRequirements(e2b=True) def __init__( self, @@ -54,9 +52,9 @@ def __init__( # ------------------------------------------------------------------ - def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[MiniF2FTaskPayload]]]: + def build_instances(self) -> Mapping[str, Sequence[Task[MiniF2FTaskPayload]]]: problems = self._load_problems() - tasks: list[BenchmarkTask[MiniF2FTaskPayload]] = [] + tasks: list[Task[MiniF2FTaskPayload]] = [] for problem in problems: payload = MiniF2FTaskPayload( name=problem.name, @@ -71,7 +69,7 @@ def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[MiniF2FTaskPayl f"{problem.formal_statement}" ) tasks.append( - BenchmarkTask[MiniF2FTaskPayload]( + Task[MiniF2FTaskPayload]( task_slug=problem.name, instance_key="default", description=description, diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rubric.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rubric.py index 5bc02aaa..8aed0c0d 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rubric.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rubric.py @@ -10,9 +10,9 @@ from collections.abc import Iterable from typing import ClassVar -from ergon_core.api.evaluator import Rubric -from ergon_core.api.results import CriterionResult, TaskEvaluationResult -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.benchmark import Task +from ergon_core.api.criterion import CriterionOutcome +from ergon_core.api.rubric import Rubric, TaskEvaluationResult from ergon_builtins.benchmarks.minif2f.criteria import build_proof_criterion @@ -43,8 +43,8 @@ def __init__( def aggregate_task( self, - task: BenchmarkTask, - criterion_results: Iterable[CriterionResult], + task: Task, + criterion_results: Iterable[CriterionOutcome], ) -> TaskEvaluationResult: results = list(criterion_results) if len(results) != 1: diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py index 706697bc..73b60411 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/rules/proof_verification.py @@ -9,10 +9,8 @@ from typing import ClassVar -from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult, CriterionScoreSpec -from ergon_core.core.runtime.evaluation.criterion_runtime import ResourceNotFoundError +from ergon_core.api.criterion import Criterion, CriterionContext, CriterionOutcome, ScoreScale +from ergon_core.core.application.evaluation.criterion_runtime import ResourceNotFoundError from pydantic import BaseModel from ergon_builtins.benchmarks.minif2f.constants import LEAN_CMD, LEAN_CMD_PREFIX @@ -62,16 +60,16 @@ def __init__( super().__init__( slug=slug, weight=weight, - score_spec=CriterionScoreSpec(max_score=max_score), + score_spec=ScoreScale(max_score=max_score), ) self.problem_statement = problem_statement self.ground_truth_proof = ground_truth_proof self.formal_system = formal_system - async def evaluate(self, context: EvaluationContext) -> CriterionResult: + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: proof_data = await self._extract_proof(context) if proof_data is None: - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=0.0, @@ -101,7 +99,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: else f"Proof verification failed:\n{outcome.errors or 'Unknown error'}" ) - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=score, @@ -115,7 +113,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: # ------------------------------------------------------------------ - async def _extract_proof(self, context: EvaluationContext) -> ExtractedProof | None: + async def _extract_proof(self, context: CriterionContext) -> ExtractedProof | None: """Read the Lean source the agent wrote, or ``None`` if missing. Reads from the task-scoped run-resource named @@ -123,10 +121,10 @@ async def _extract_proof(self, context: EvaluationContext) -> ExtractedProof | N ``SandboxResourcePublisher.sync()`` after the worker writes to ``/workspace/final_output/final_solution.lean``. """ - if context.runtime is None: + if not context.has_runtime: return None try: - raw = await context.runtime.read_resource("final_solution.lean") + raw = await context.read_resource("final_solution.lean") except ResourceNotFoundError: return None return ExtractedProof( @@ -137,7 +135,7 @@ async def _extract_proof(self, context: EvaluationContext) -> ExtractedProof | N async def _verify_proof( self, - context: EvaluationContext, + context: CriterionContext, proof_code: str, ) -> ProofVerificationOutcome: """Write proof into sandbox and run Lean verification. @@ -165,19 +163,18 @@ async def _verify_proof( # back-door. `_extract_proof` above already reads via # `context.runtime.read_resource`; this keeps `_verify_proof` # consistent and unblocks deletion of the metadata shim. - runtime = context.runtime - if runtime is None: + if not context.has_runtime: return ProofVerificationOutcome( verified=False, errors="No criterion runtime in evaluation context.", ) - await runtime.write_file( + await context.write_file( "/tools/mathlib_project/src/verify.lean", proof_code.encode("utf-8"), ) - result = await runtime.run_command(VERIFY_LEAN_CMD, timeout=120) + result = await context.run_command(VERIFY_LEAN_CMD, timeout=120) stdout = "" if result.stdout is None else result.stdout stderr = "" if result.stderr is None else result.stderr diff --git a/ergon_builtins/ergon_builtins/benchmarks/minif2f/sandbox_manager.py b/ergon_builtins/ergon_builtins/benchmarks/minif2f/sandbox_manager.py index e9f8c650..c605ab06 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/minif2f/sandbox_manager.py +++ b/ergon_builtins/ergon_builtins/benchmarks/minif2f/sandbox_manager.py @@ -10,7 +10,7 @@ class ``_install_dependencies`` hook is sufficient — the verify step just import logging from uuid import UUID -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.minif2f.sandbox.utils import ( REGISTRY_PATH, diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py index 2ae03c96..0f25ff4c 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/benchmark.py @@ -8,9 +8,7 @@ from typing import Any, ClassVar from datasets import load_dataset -from ergon_core.api.benchmark import Benchmark -from ergon_core.api.benchmark_deps import BenchmarkDeps -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.benchmark import Benchmark, BenchmarkRequirements, Task from ergon_builtins.benchmarks.researchrubrics.task_schemas import ( ResearchRubricsTaskPayload, @@ -30,7 +28,7 @@ class ResearchRubricsBenchmark(Benchmark): type_slug: ClassVar[str] = "researchrubrics" dataset_name: ClassVar[str] = "ScaleAI/researchrubrics" task_payload_model: ClassVar[type[ResearchRubricsTaskPayload]] = ResearchRubricsTaskPayload - onboarding_deps: ClassVar[BenchmarkDeps] = BenchmarkDeps( + onboarding_deps: ClassVar[BenchmarkRequirements] = BenchmarkRequirements( extras=("ergon-builtins[data]",), optional_keys=("EXA_API_KEY",), ) @@ -54,12 +52,12 @@ def __init__( # ------------------------------------------------------------------ - def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[ResearchRubricsTaskPayload]]]: + def build_instances(self) -> Mapping[str, Sequence[Task[ResearchRubricsTaskPayload]]]: payloads = self._load_rows() - tasks: list[BenchmarkTask[ResearchRubricsTaskPayload]] = [] + tasks: list[Task[ResearchRubricsTaskPayload]] = [] for payload in payloads: tasks.append( - BenchmarkTask[ResearchRubricsTaskPayload]( + Task[ResearchRubricsTaskPayload]( task_slug=payload.sample_id, instance_key="default", description=payload.prompt, @@ -80,7 +78,7 @@ def _load_rows(self) -> list[ResearchRubricsTaskPayload]: Requires ``datasets`` and ``huggingface_hub`` to be installed. """ # reason: avoids circular import at module level - from ergon_core.core.settings import settings + from ergon_core.core.shared.settings import settings token = settings.hf_api_key ds = load_dataset(self.dataset_name, token=token) diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py index 4075ef84..e15c5232 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/judge_criterion.py @@ -1,14 +1,15 @@ from typing import ClassVar -from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import ( - CriterionObservation, - CriterionObservationMessage, - CriterionResult, - CriterionScoreSpec, +from ergon_core.api.criterion import ( + Criterion, + CriterionContext, + CriterionEvidence, + CriterionOutcome, + EvidenceMessage, + ScoreScale, ) -from ergon_core.core.runtime.resources import RunResourceKind, RunResourceView +from ergon_core.core.application.resources import RunResourceView +from ergon_core.core.persistence.shared.enums import RunResourceKind from pydantic import BaseModel from ergon_builtins.benchmarks.researchrubrics.task_schemas import RubricCriterion @@ -46,13 +47,13 @@ def __init__( slug=slug, description=rubric.criterion, weight=rubric.weight, - score_spec=CriterionScoreSpec(max_score=abs(rubric.weight)), + score_spec=ScoreScale(max_score=abs(rubric.weight)), ) self.rubric = rubric self.model = model self.system_prompt = self._build_system_prompt(rubric) - async def evaluate(self, context: EvaluationContext) -> CriterionResult: + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: final_outputs, scratch_outputs = await self._load_researchrubrics_evidence(context) user_prompt = self._build_user_prompt( context, @@ -66,7 +67,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: evaluated_resource_ids = [ str(evidence.resource.id) for evidence in [*final_outputs, *scratch_outputs] ] - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=self.score_spec.max_score if verdict.passed else 0.0, @@ -99,11 +100,11 @@ def _build_observation( final_outputs: list[_ResourceEvidence], rubric: RubricCriterion, model: str, - ) -> CriterionObservation: - return CriterionObservation( + ) -> CriterionEvidence: + return CriterionEvidence( prompt_messages=[ - CriterionObservationMessage(role="system", content=system_prompt), - CriterionObservationMessage(role="user", content=user_prompt), + EvidenceMessage(role="system", content=system_prompt), + EvidenceMessage(role="user", content=user_prompt), ], evidence_resource_ids=evaluated_resource_ids, output=verdict.model_dump(mode="json"), @@ -137,16 +138,16 @@ async def _call_judge( @classmethod async def _load_researchrubrics_evidence( cls, - context: EvaluationContext, + context: CriterionContext, ) -> tuple[list[_ResourceEvidence], list[_ResourceEvidence]]: - if context.runtime is None: + if not context.has_runtime: return [], [] - resources = await context.runtime.list_resources() + resources = await context.list_resources() evidence: list[_ResourceEvidence] = [] for resource in resources: try: - raw_content = await context.runtime.read_resource_by_id(resource.id) + raw_content = await context.read_resource_by_id(resource.id) except OSError as exc: text = f"[Unable to read resource {resource.id}: {exc}]" else: @@ -208,7 +209,7 @@ def _build_system_prompt(cls, criterion: RubricCriterion) -> str: @classmethod def _build_user_prompt( cls, - context: EvaluationContext, + context: CriterionContext, *, final_outputs: list[_ResourceEvidence], scratch_outputs: list[_ResourceEvidence], diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py index 7399edaa..e82c5cf4 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/rubric.py @@ -12,9 +12,9 @@ from collections.abc import Iterable, Sequence from typing import ClassVar -from ergon_core.api.evaluator import Rubric -from ergon_core.api.results import CriterionResult, TaskEvaluationResult -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.benchmark import Task +from ergon_core.api.criterion import CriterionOutcome +from ergon_core.api.rubric import Rubric, TaskEvaluationResult from ergon_builtins.benchmarks.researchrubrics.criteria import build_criteria_from_rubrics from ergon_builtins.benchmarks.researchrubrics.task_schemas import ( @@ -43,7 +43,7 @@ def __init__( super().__init__(name=name, criteria=criteria) self._rubric_criteria = tuple(rubric_criteria) - def criteria_for(self, task: BenchmarkTask): + def criteria_for(self, task: Task): """Build task-specific LLM-judge criteria from the task payload.""" if self._rubric_criteria: return self.criteria @@ -54,8 +54,8 @@ def criteria_for(self, task: BenchmarkTask): def aggregate_task( self, - task: BenchmarkTask, - criterion_results: Iterable[CriterionResult], + task: Task, + criterion_results: Iterable[CriterionOutcome], ) -> TaskEvaluationResult: results = list(criterion_results) if not results: diff --git a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/sandbox_manager.py b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/sandbox_manager.py index d512b661..f2b502d7 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/sandbox_manager.py +++ b/ergon_builtins/ergon_builtins/benchmarks/researchrubrics/sandbox_manager.py @@ -10,8 +10,8 @@ from typing import ClassVar from uuid import UUID -from ergon_core.core.sandbox.manager import BaseSandboxManager -from ergon_core.core.sandbox.resource_publisher import ( +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.resource_publisher import ( SandboxResourcePublisher, ) diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/benchmark.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/benchmark.py index 31e09b66..84d589fe 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/benchmark.py +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/benchmark.py @@ -10,9 +10,7 @@ from typing import Any, ClassVar from datasets import load_dataset -from ergon_core.api.benchmark import Benchmark -from ergon_core.api.benchmark_deps import BenchmarkDeps -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.benchmark import Benchmark, BenchmarkRequirements, Task from ergon_builtins.benchmarks.swebench_verified.task_schemas import ( SWEBenchInstance, @@ -30,7 +28,7 @@ class SweBenchVerifiedBenchmark(Benchmark): type_slug: ClassVar[str] = "swebench-verified" task_payload_model: ClassVar[type[SWEBenchTaskPayload]] = SWEBenchTaskPayload - onboarding_deps: ClassVar[BenchmarkDeps] = BenchmarkDeps( + onboarding_deps: ClassVar[BenchmarkRequirements] = BenchmarkRequirements( e2b=True, extras=("ergon-builtins[data]",), ) @@ -50,13 +48,13 @@ def __init__( ) self.limit = limit - def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[SWEBenchTaskPayload]]]: + def build_instances(self) -> Mapping[str, Sequence[Task[SWEBenchTaskPayload]]]: instances = _load_rows(limit=self.limit) - tasks: list[BenchmarkTask[SWEBenchTaskPayload]] = [] + tasks: list[Task[SWEBenchTaskPayload]] = [] for instance in instances: payload = SWEBenchTaskPayload.from_instance(instance) tasks.append( - BenchmarkTask[SWEBenchTaskPayload]( + Task[SWEBenchTaskPayload]( task_slug=instance.instance_id, instance_key="default", description=payload.build_worker_description(), diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py index d0075dcc..b8a95d89 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/criterion.py @@ -20,9 +20,8 @@ from typing import Any, ClassVar from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult -from ergon_core.core.runtime.evaluation.protocols import CriterionRuntime +from ergon_core.api.criterion import CriterionContext, CriterionOutcome +from ergon_core.core.application.evaluation.protocols import CriterionRuntime from ergon_builtins.benchmarks.swebench_verified.sandbox_manager_support import ( payload_to_swebench_row as _payload_to_swebench_row, @@ -37,20 +36,15 @@ PATCH_EXTRACT_TIMEOUT_SEC = 120 -async def _extract_patch_via_runtime(context: EvaluationContext) -> str: +async def _extract_patch_via_runtime(context: CriterionContext) -> str: """Compute ``git add -A && git diff HEAD`` via the criterion runtime. The criterion owns patch extraction; the sandbox working tree is the only reliable source of truth (nothing crosses the durable Inngest ``worker_execute`` boundary). """ - if context.runtime is None: - raise RuntimeError( - "SWEBenchTestCriterion requires a CriterionRuntime for patch " - "extraction; none was injected into EvaluationContext." - ) - await context.runtime.ensure_sandbox() - result = await context.runtime.run_command( + await context.ensure_sandbox() + result = await context.run_command( f"cd {WORKDIR} && git add -A && git diff HEAD", timeout=PATCH_EXTRACT_TIMEOUT_SEC, ) @@ -132,10 +126,10 @@ def __init__( ) -> None: super().__init__(slug=slug, weight=weight) - async def evaluate(self, context: EvaluationContext) -> CriterionResult: + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: patch_text = await _extract_patch_via_runtime(context) if not patch_text.strip(): - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=0.0, @@ -155,24 +149,20 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: # `sandbox_manager` attribute. `_extract_patch_via_runtime` above # already called `ensure_sandbox`, so subsequent `run_command` / # `write_file` calls are guaranteed to hit a live sandbox. - runtime = context.runtime - if runtime is None: # pragma: no cover — guarded above - raise RuntimeError("runtime disappeared after patch extraction") - return await self._run_and_grade( - runtime=runtime, spec=spec, payload=payload, patch_text=patch_text + context=context, spec=spec, payload=payload, patch_text=patch_text ) async def _run_and_grade( self, *, - runtime: CriterionRuntime, + context: CriterionContext, spec: Any, # slopcop: ignore[no-typing-any] payload: SWEBenchTaskPayload, patch_text: str, - ) -> CriterionResult: + ) -> CriterionOutcome: # 1. install_repo_script: clone + checkout base_commit + install deps. - r = await runtime.run_command( + r = await context.run_command( f"bash -c {shlex.quote(spec.install_repo_script)}", timeout=EVAL_TIMEOUT_SEC, ) @@ -193,13 +183,13 @@ async def _run_and_grade( test_patch = payload.test_patch try: if test_patch.strip(): - await _write_and_apply(runtime, "/tmp/test.patch", test_patch) - await _write_and_apply(runtime, "/tmp/agent.patch", patch_text) + await _write_and_apply(context, "/tmp/test.patch", test_patch) + await _write_and_apply(context, "/tmp/agent.patch", patch_text) except RuntimeError as exc: return _error_result(self.slug, self.weight, "git apply failed", str(exc)) # 3. Run eval script with stderr merged so the log has everything. - r = await runtime.run_command( + r = await context.run_command( f"bash -c {shlex.quote(spec.eval_script)} 2>&1", timeout=EVAL_TIMEOUT_SEC, ) @@ -214,7 +204,7 @@ async def _run_and_grade( ) entry = report.get(payload.instance_id, {}) if isinstance(report, dict) else {} resolved = bool(entry.get("resolved")) - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=1.0 if resolved else 0.0, @@ -226,7 +216,7 @@ async def _run_and_grade( async def _write_and_apply( - runtime: CriterionRuntime, + context: CriterionContext, path: str, content: str, ) -> None: @@ -235,13 +225,13 @@ async def _write_and_apply( Falls back to ``--3way`` if the straight apply fails. Raises ``RuntimeError`` with tail of stdout when both attempts fail. """ - await runtime.write_file(path, content.encode()) - r = await runtime.run_command( + await context.write_file(path, content.encode()) + r = await context.run_command( f"cd {WORKDIR} && git apply --allow-empty --verbose {path}", timeout=APPLY_TIMEOUT_SEC, ) if r.exit_code != 0: - r = await runtime.run_command( + r = await context.run_command( f"cd {WORKDIR} && git apply --3way --verbose {path}", timeout=APPLY_TIMEOUT_SEC, ) @@ -250,8 +240,8 @@ async def _write_and_apply( raise RuntimeError(f"git apply {path} failed: {stdout[-800:]}") -def _error_result(slug: str, weight: float, kind: str, detail: str) -> CriterionResult: - return CriterionResult( +def _error_result(slug: str, weight: float, kind: str, detail: str) -> CriterionOutcome: + return CriterionOutcome( slug=slug, name=slug, score=0.0, diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/rubric.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/rubric.py new file mode 100644 index 00000000..1680e23a --- /dev/null +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/rubric.py @@ -0,0 +1,21 @@ +"""Evaluator rubric for SWE-Bench Verified.""" + +from typing import ClassVar + +from ergon_core.api.rubric import Rubric + +from ergon_builtins.benchmarks.swebench_verified.criterion import ( + SWEBenchTestCriterion, +) + + +class SWEBenchRubric(Rubric): + """Rubric wrapping the SWE-Bench test-resolution criterion.""" + + type_slug: ClassVar[str] = "swebench-rubric" + + def __init__(self, *, name: str = "swebench-rubric") -> None: + super().__init__( + name=name, + criteria=[SWEBenchTestCriterion(slug="test-resolution", weight=1.0)], + ) diff --git a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/sandbox_manager.py b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/sandbox_manager.py index 8f4a7ed2..7ca745cd 100644 --- a/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/sandbox_manager.py +++ b/ergon_builtins/ergon_builtins/benchmarks/swebench_verified/sandbox_manager.py @@ -4,17 +4,18 @@ the right Python version, installing deps) is driven by ``swebench.harness.test_spec`` and runs inside ``_install_dependencies`` so it executes exactly once per sandbox_key. -The task payload is fetched from the data layer (``queries.task_executions. -get_task_payload``) rather than piggy-backing on the Inngest event. +The task payload is fetched from the task repository rather than piggy-backing +on the Inngest event. """ import logging import shlex from uuid import UUID -from ergon_core.core.persistence.queries import queries -from ergon_core.core.sandbox.errors import SandboxSetupError -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.application.tasks.repository import TaskExecutionRepository +from ergon_core.core.infrastructure.sandbox.errors import SandboxSetupError +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.swebench_verified.criterion import make_test_spec from ergon_builtins.benchmarks.swebench_verified.sandbox.utils import resolve_template @@ -73,7 +74,12 @@ async def _install_dependencies(self, sandbox: AsyncSandbox, task_id: UUID) -> N ``BaseSandboxManager.create()`` — the early-return at ``create()`` guards idempotence, so re-entry does not re-run these scripts. """ - payload = queries.task_executions.get_task_payload(task_id, SWEBenchTaskPayload) + with get_session() as session: + payload = TaskExecutionRepository().task_payload_for_execution( + session, + task_id, + SWEBenchTaskPayload, + ) if payload is None: raise SandboxSetupError( f"No task_payload for task_id={task_id}; prepare step must commit " diff --git a/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py b/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py index cbec5880..f5831bc2 100644 --- a/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py +++ b/ergon_builtins/ergon_builtins/common/llm_context/adapters/base.py @@ -2,7 +2,7 @@ from typing import Protocol, TypeVar -from ergon_core.core.generation import ContextPartChunk +from ergon_core.core.domain.generation.context_parts import ContextPartChunk from ergon_core.core.persistence.context.models import RunContextEvent TranscriptT = TypeVar("TranscriptT") diff --git a/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py b/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py index 250d1a1e..2067087f 100644 --- a/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py +++ b/ergon_builtins/ergon_builtins/common/llm_context/adapters/pydantic_ai.py @@ -2,7 +2,7 @@ import json -from ergon_core.core.generation import ( +from ergon_core.core.domain.generation.context_parts import ( AssistantTextPart, ContextPartChunk, ContextPartChunkLog, diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py b/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py index 35ca6d26..1eb4fa92 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/code_check.py @@ -7,9 +7,7 @@ from typing import ClassVar -from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult, CriterionScoreSpec +from ergon_core.api.criterion import Criterion, CriterionContext, CriterionOutcome, ScoreScale class CodeCheckCriterion(Criterion): @@ -36,15 +34,15 @@ def __init__( slug=slug, description=description or slug, weight=weight, - score_spec=CriterionScoreSpec(max_score=max_score), + score_spec=ScoreScale(max_score=max_score), ) self.code_template = code_template - async def evaluate(self, context: EvaluationContext) -> CriterionResult: + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: output = context.worker_result.output passed = bool(output and len(output.strip()) > 0) score = self.score_spec.max_score if passed else 0.0 - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=score, diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py index ee7b8243..6df1eadb 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/llm_judge.py @@ -8,9 +8,7 @@ from typing import ClassVar -from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult, CriterionScoreSpec +from ergon_core.api.criterion import Criterion, CriterionContext, CriterionOutcome, ScoreScale from pydantic import BaseModel from ergon_builtins.common.llm.structured_judge import ( @@ -49,12 +47,12 @@ def __init__( slug=slug, description=description or slug, weight=weight, - score_spec=CriterionScoreSpec(max_score=max_score), + score_spec=ScoreScale(max_score=max_score), ) self.prompt_template = prompt_template self.model = model - async def evaluate(self, context: EvaluationContext) -> CriterionResult: + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: messages = [ JudgeMessage(role="system", content=self.prompt_template), JudgeMessage( @@ -73,7 +71,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: ) score = self.score_spec.max_score if verdict.passed else 0.0 - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=score, diff --git a/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py b/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py index be5ac0ee..d027b6f1 100644 --- a/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py +++ b/ergon_builtins/ergon_builtins/evaluators/criteria/sandbox_file_check.py @@ -4,7 +4,8 @@ worker's sandbox via sandbox_id and checks for the expected file. """ -from ergon_core.api import Criterion, CriterionResult, EvaluationContext +from ergon_core.api.criterion import Criterion, CriterionContext, CriterionOutcome +from e2b_code_interpreter import AsyncSandbox MARKER_PATH = "/outputs/ci_marker.txt" MARKER_CONTENT = "smoke-test-marker" @@ -25,9 +26,9 @@ def __init__( self.expected_path = expected_path self.expected_content = expected_content - async def evaluate(self, context: EvaluationContext) -> CriterionResult: + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: if not context.sandbox_id: - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=0.0, @@ -36,18 +37,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: feedback="No sandbox_id available — cannot check files", ) - try: - # Deferred: optional dependency - from e2b_code_interpreter import AsyncSandbox - except ImportError: - return CriterionResult( - slug=self.slug, - name=self.slug, - score=0.0, - passed=False, - weight=self.weight, - feedback="e2b_code_interpreter not installed", - ) + try: sandbox = await AsyncSandbox.connect(sandbox_id=context.sandbox_id) @@ -57,7 +47,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: content = content.decode("utf-8") found = self.expected_content in content - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=1.0 if found else 0.0, @@ -71,7 +61,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: ), ) except Exception as exc: # slopcop: ignore[no-broad-except] - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=0.0, diff --git a/ergon_builtins/ergon_builtins/evaluators/rubrics/swebench_rubric.py b/ergon_builtins/ergon_builtins/evaluators/rubrics/swebench_rubric.py index 19c952f5..1ab3f5d7 100644 --- a/ergon_builtins/ergon_builtins/evaluators/rubrics/swebench_rubric.py +++ b/ergon_builtins/ergon_builtins/evaluators/rubrics/swebench_rubric.py @@ -1,21 +1,3 @@ -"""Evaluator rubric for SWE-Bench Verified: one test-resolution criterion.""" +"""Compatibility import for the benchmark-owned SWE-Bench rubric.""" -from typing import ClassVar - -from ergon_core.api.evaluator import Rubric - -from ergon_builtins.benchmarks.swebench_verified.criterion import ( - SWEBenchTestCriterion, -) - - -class SWEBenchRubric(Rubric): - """Rubric wrapping the SWE-Bench test-resolution criterion.""" - - type_slug: ClassVar[str] = "swebench-rubric" - - def __init__(self, *, name: str = "swebench-rubric") -> None: - super().__init__( - name=name, - criteria=[SWEBenchTestCriterion(slug="test-resolution", weight=1.0)], - ) +from ergon_builtins.benchmarks.swebench_verified.rubric import SWEBenchRubric diff --git a/ergon_builtins/ergon_builtins/models/openrouter_backend.py b/ergon_builtins/ergon_builtins/models/openrouter_backend.py index 9e6d66d8..f130f8c6 100644 --- a/ergon_builtins/ergon_builtins/models/openrouter_backend.py +++ b/ergon_builtins/ergon_builtins/models/openrouter_backend.py @@ -2,7 +2,7 @@ import logging -from ergon_core.core.settings import settings +from ergon_core.core.shared.settings import settings from pydantic_ai.models.openrouter import OpenRouterModel, OpenRouterProvider from ergon_builtins.models.resolution import ResolvedModel diff --git a/ergon_builtins/ergon_builtins/models/openrouter_responses_backend.py b/ergon_builtins/ergon_builtins/models/openrouter_responses_backend.py index eff83523..41399093 100644 --- a/ergon_builtins/ergon_builtins/models/openrouter_responses_backend.py +++ b/ergon_builtins/ergon_builtins/models/openrouter_responses_backend.py @@ -2,7 +2,7 @@ import logging -from ergon_core.core.settings import settings +from ergon_core.core.shared.settings import settings from pydantic_ai.models.openai import OpenAIResponsesModel from pydantic_ai.providers.openai import OpenAIProvider diff --git a/ergon_builtins/ergon_builtins/models/resolution.py b/ergon_builtins/ergon_builtins/models/resolution.py index 111d8623..9fcb0491 100644 --- a/ergon_builtins/ergon_builtins/models/resolution.py +++ b/ergon_builtins/ergon_builtins/models/resolution.py @@ -4,7 +4,7 @@ from collections.abc import Callable import pydantic_ai.models -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject from pydantic import BaseModel from pydantic_ai.models.openrouter import OpenRouterReasoning diff --git a/ergon_builtins/ergon_builtins/tools/graph_toolkit.py b/ergon_builtins/ergon_builtins/tools/graph_toolkit.py index 804fa669..854a755e 100644 --- a/ergon_builtins/ergon_builtins/tools/graph_toolkit.py +++ b/ergon_builtins/ergon_builtins/tools/graph_toolkit.py @@ -1,6 +1,6 @@ """ResearchGraphToolkit — run-scoped resource discovery for research workers. -Six pydantic-ai tools backed by ``ResourcesQueries`` and ``RunGraphEdge`` +Six pydantic-ai tools backed by resource and task repositories traversal so workers can enumerate their own, children's, and descendants' resources, plus lookup by logical_path / content_hash. """ @@ -8,8 +8,10 @@ from collections.abc import Sequence from uuid import UUID -from ergon_core.core.persistence.queries import queries +from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource +from ergon_core.core.application.resources import RunResourceRepository +from ergon_core.core.application.tasks.repository import TaskExecutionRepository from pydantic_ai import RunContext from pydantic_ai.tools import Tool @@ -30,6 +32,8 @@ class ResearchGraphToolkit: def __init__(self, *, run_id: UUID, task_execution_id: UUID) -> None: self._run_id = run_id self._task_execution_id = task_execution_id + self._resource_repo = RunResourceRepository() + self._task_repo = TaskExecutionRepository() def build_tools(self) -> list["Tool"]: """Return the six resource-discovery tools for ``Agent(tools=[...])``.""" @@ -66,7 +70,8 @@ async def list_my_resources( > tool_budget.max_other_tool_calls ): return tool_budget.exhausted_result("non-workflow tool budget reached") - rows = queries.resources.list_by_execution(task_execution_id) + with get_session() as session: + rows = self._resource_repo.list_by_execution(session, task_execution_id) return _to_refs_sorted( [r for r in rows if r.run_id == run_id], ) @@ -95,10 +100,15 @@ async def list_child_resources( > tool_budget.max_other_tool_calls ): return tool_budget.exhausted_result("non-workflow tool budget reached") - children = queries.task_executions.list_children_of(task_execution_id) + with get_session() as session: + children = self._task_repo.list_children_of_execution( + session, + task_execution_id, + ) result: list[RunResource] = [] for child in children: - rows = queries.resources.list_by_execution(child.id) + with get_session() as session: + rows = self._resource_repo.list_by_execution(session, child.id) result.extend(r for r in rows if r.run_id == run_id) return _to_refs_sorted(result) @@ -138,15 +148,18 @@ async def list_descendant_resources( for _depth in range(max_depth): next_frontier: list[UUID] = [] for parent_id in frontier: - children = queries.task_executions.list_children_of( - parent_id, - ) + with get_session() as session: + children = self._task_repo.list_children_of_execution( + session, + parent_id, + ) for child in children: if child.id in visited: continue visited.add(child.id) next_frontier.append(child.id) - rows = queries.resources.list_by_execution(child.id) + with get_session() as session: + rows = self._resource_repo.list_by_execution(session, child.id) result.extend(r for r in rows if r.run_id == run_id) frontier = next_frontier if not frontier: @@ -177,7 +190,8 @@ async def list_run_resources( > tool_budget.max_other_tool_calls ): return tool_budget.exhausted_result("non-workflow tool budget reached") - rows = queries.resources.list_by_run(run_id) + with get_session() as session: + rows = self._resource_repo.list_by_run(session, run_id) return _to_refs_sorted(rows) return Tool(function=list_run_resources, takes_ctx=True) @@ -207,7 +221,8 @@ async def get_resource_by_logical_path( > tool_budget.max_other_tool_calls ): return tool_budget.exhausted_result("non-workflow tool budget reached") - rows = queries.resources.list_by_run(run_id) + with get_session() as session: + rows = self._resource_repo.list_by_run(session, run_id) matching = [r for r in rows if r.file_path == logical_path] if not matching: return None @@ -241,7 +256,8 @@ async def get_resource_by_content_hash( > tool_budget.max_other_tool_calls ): return tool_budget.exhausted_result("non-workflow tool budget reached") - rows = queries.resources.list_by_run(run_id) + with get_session() as session: + rows = self._resource_repo.list_by_run(session, run_id) matching = [r for r in rows if r.content_hash == content_hash] if not matching: return None diff --git a/ergon_builtins/ergon_builtins/tools/graph_toolkit_types.py b/ergon_builtins/ergon_builtins/tools/graph_toolkit_types.py index a4412fc3..3a3028bc 100644 --- a/ergon_builtins/ergon_builtins/tools/graph_toolkit_types.py +++ b/ergon_builtins/ergon_builtins/tools/graph_toolkit_types.py @@ -7,7 +7,8 @@ from datetime import datetime from uuid import UUID -from ergon_core.core.runtime.resources import RunResourceKind, RunResourceView +from ergon_core.core.application.resources import RunResourceView +from ergon_core.core.persistence.shared.enums import RunResourceKind from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution from pydantic import BaseModel, ConfigDict, Field diff --git a/ergon_builtins/ergon_builtins/tools/subtask_lifecycle_toolkit.py b/ergon_builtins/ergon_builtins/tools/subtask_lifecycle_toolkit.py index c2483b4a..cb64179a 100644 --- a/ergon_builtins/ergon_builtins/tools/subtask_lifecycle_toolkit.py +++ b/ergon_builtins/ergon_builtins/tools/subtask_lifecycle_toolkit.py @@ -17,9 +17,9 @@ RunId, TaskSlug, ) -from ergon_core.core.runtime.services.task_inspection_dto import SubtaskInfo -from ergon_core.core.runtime.services.task_inspection_service import TaskInspectionService -from ergon_core.core.runtime.services.task_management_dto import ( +from ergon_core.core.application.tasks.models import SubtaskInfo +from ergon_core.core.application.tasks.inspection import TaskInspectionService +from ergon_core.core.application.tasks.models import ( AddSubtaskCommand, CancelTaskCommand, PlanSubtasksCommand, @@ -27,7 +27,7 @@ RestartTaskCommand, SubtaskSpec, ) -from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from ergon_core.core.application.tasks.management import TaskManagementService from pydantic import BaseModel from ergon_builtins.tools.bash_sandbox_tool import make_sandbox_bash_tool diff --git a/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py b/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py index 9bd74e25..332a7d5b 100644 --- a/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py +++ b/ergon_builtins/ergon_builtins/tools/workflow_cli_tool.py @@ -8,9 +8,9 @@ WorkflowCommandOutput, execute_workflow_command, ) -from ergon_core.api.worker_context import WorkerContext +from ergon_core.api import WorkerContext from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.runtime.services.workflow_service import WorkflowService +from ergon_core.core.application.workflows.service import WorkflowService from pydantic_ai import RunContext from sqlmodel import Session diff --git a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py index 452bbedd..484abcd8 100644 --- a/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/baselines/react_worker.py @@ -8,15 +8,13 @@ from typing import Any, Self, cast from uuid import UUID -from ergon_core.api import BenchmarkTask, Worker, WorkerContext, WorkerOutput -from ergon_core.core.generation import ( +from ergon_core.api import Task, Worker, WorkerContext, WorkerOutput, WorkerStreamItem +from ergon_core.core.domain.generation.context_parts import ( AssistantTextPart, ContextPartChunk, - ThinkingPart, ToolCallPart, ) -from ergon_core.core.persistence.context.repository import ContextEventRepository -from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.application.context.events import ContextEventService from pydantic import BaseModel from pydantic_ai import Agent from pydantic_ai.messages import ModelMessage @@ -75,10 +73,10 @@ def __init__( async def execute( self, - task: BenchmarkTask, + task: Task, *, context: WorkerContext, - ) -> AsyncGenerator[ContextPartChunk, None]: + ) -> AsyncGenerator[WorkerStreamItem, None]: async for chunk in self._run_agent(task, context): yield chunk @@ -89,9 +87,9 @@ def build_agent_deps( async def _run_agent( self, - task: BenchmarkTask, + task: Task, context: WorkerContext, - ) -> AsyncGenerator[ContextPartChunk, None]: + ) -> AsyncGenerator[WorkerStreamItem, None]: """Run the underlying pydantic-ai agent and yield the chunks it produced.""" resolved = resolve_model_target(self.model) configure_pydantic_ai_logfire() @@ -113,6 +111,7 @@ async def _run_agent( node_count = 0 adapter = PydanticAITranscriptAdapter() cursor = TranscriptTurnCursor() + emitted_chunks: list[ContextPartChunk] = [] run = None try: @@ -130,6 +129,7 @@ async def _run_agent( cursor, flush_pending=False, ): + emitted_chunks.append(chunk) yield chunk if node_count >= self.max_iterations: logger.warning( @@ -141,6 +141,7 @@ async def _run_agent( cursor, flush_pending=True, ): + emitted_chunks.append(chunk) yield chunk raise RuntimeError( f"ReActWorker exceeded max_iterations={self.max_iterations}" @@ -152,6 +153,7 @@ async def _run_agent( cursor, flush_pending=True, ): + emitted_chunks.append(chunk) yield chunk raise @@ -161,44 +163,10 @@ async def _run_agent( cursor, flush_pending=True, ): + emitted_chunks.append(chunk) yield chunk - def get_output(self, context: WorkerContext) -> WorkerOutput: - """Extract the agent's text output from the last context event.""" - return self._base_output(context) - - def _base_output(self, context: WorkerContext) -> WorkerOutput: - """Build the worker's output from persisted context events.""" - with get_session() as session: - repo = ContextEventRepository() - events = repo.get_for_execution(session, context.execution_id) - - turn_ids: set[str] = set() - for e in events: - payload = e.parsed_payload() - if isinstance(payload.part, (AssistantTextPart, ToolCallPart, ThinkingPart)): - if payload.turn_id is None: - continue - turn_ids.add(payload.turn_id) - - text_events = [e for e in events if e.event_type == "assistant_text"] - if not text_events: - output = _latest_final_result_message(events) - if not output: - return WorkerOutput(output="", success=False) - return WorkerOutput( - output=output, - success=bool(output), - metadata={"turn_count": len(turn_ids)}, - ) - last = text_events[-1].parsed_payload() - if not isinstance(last.part, AssistantTextPart): - raise ValueError(f"Expected AssistantTextPart, got {type(last.part)}") - return WorkerOutput( - output=last.part.content, - success=True, - metadata={"turn_count": len(turn_ids)}, - ) + yield _worker_output_from_chunks(emitted_chunks) @classmethod def from_buffer( @@ -208,7 +176,7 @@ def from_buffer( **kwargs: Any, # slopcop: ignore[no-typing-any] ) -> Self | None: """Return a ReActWorker pre-seeded with context event history.""" - repo = ContextEventRepository() + repo = ContextEventService() events = repo.get_for_execution(session, execution_id) if not events: return None @@ -217,7 +185,7 @@ def from_buffer( return worker -def _format_task(task: BenchmarkTask) -> str: +def _format_task(task: Task) -> str: lines = [f"Task: {task.description}"] payload = task.task_payload.model_dump(mode="json") if payload: @@ -226,20 +194,23 @@ def _format_task(task: BenchmarkTask) -> str: return "\n".join(lines) -def _latest_final_result_message( - events: list[Any], # slopcop: ignore[no-typing-any] -) -> str: +def _worker_output_from_chunks(chunks: list[ContextPartChunk]) -> WorkerOutput: + output = _latest_final_result_message(chunks) + if output: + return WorkerOutput(output=output, success=True) + + text_parts = [chunk.part.content for chunk in chunks if isinstance(chunk.part, AssistantTextPart)] + if text_parts: + return WorkerOutput(output=text_parts[-1], success=True) + + return WorkerOutput(output="", success=False) + + +def _latest_final_result_message(chunks: list[ContextPartChunk]) -> str: """Extract fallback text from the latest ``final_result`` tool call.""" messages: list[str] = [] - for event in events: - try: - event_type = event.event_type - except AttributeError: - continue - if event_type != "tool_call": - continue - payload = event.parsed_payload() - part = payload.part + for chunk in chunks: + part = chunk.part if not isinstance(part, ToolCallPart) or part.tool_name != "final_result": continue messages.append(str(part.args.get("final_assistant_message", ""))) diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py index 33433d14..aae81d41 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/researcher_worker.py @@ -10,10 +10,8 @@ from typing import ClassVar from uuid import UUID -from ergon_core.core.generation import ContextPartChunk -from ergon_core.core.runtime.resources import RunResourceView -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.api.worker_context import WorkerContext +from ergon_core.api import Task, WorkerContext, WorkerStreamItem +from ergon_core.core.application.resources import RunResourceView from ergon_builtins.benchmarks.researchrubrics.sandbox_manager import ( ResearchRubricsSandboxManager, ) @@ -117,10 +115,10 @@ def build_agent_deps(self, context: WorkerContext) -> AgentToolBudgetDeps: async def execute( self, - task: BenchmarkTask, + task: Task, *, context: WorkerContext, - ) -> AsyncGenerator[ContextPartChunk, None]: + ) -> AsyncGenerator[WorkerStreamItem, None]: manager = ResearchRubricsSandboxManager() model_run_skill = make_run_skill(model=self.model) diff --git a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py index 6dd7dee1..761cd16a 100644 --- a/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py +++ b/ergon_builtins/ergon_builtins/workers/research_rubrics/workflow_cli_react_worker.py @@ -3,10 +3,8 @@ from typing import ClassVar from uuid import UUID -from ergon_core.core.generation import ContextPartChunk -from ergon_core.core.runtime.resources import RunResourceView -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.api.worker_context import WorkerContext +from ergon_core.api import Task, WorkerContext, WorkerStreamItem +from ergon_core.core.application.resources import RunResourceView from ergon_builtins.benchmarks.researchrubrics.sandbox_manager import ( ResearchRubricsSandboxManager, ) @@ -125,10 +123,10 @@ def build_agent_deps(self, context: WorkerContext) -> AgentToolBudgetDeps: async def execute( self, - task: BenchmarkTask, + task: Task, *, context: WorkerContext, - ) -> AsyncGenerator[ContextPartChunk, None]: + ) -> AsyncGenerator[WorkerStreamItem, None]: manager = ResearchRubricsSandboxManager() model_run_skill = make_run_skill(model=self.model) From 52021862ad5eb30335186ff049a81bca003af2b0 Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:46 +0100 Subject: [PATCH 64/66] refactor: update CLI experiment and benchmark flows Made-with: Cursor --- ergon_cli/ergon_cli/commands/benchmark.py | 143 +++++++------------- ergon_cli/ergon_cli/commands/doctor.py | 4 +- ergon_cli/ergon_cli/commands/experiment.py | 69 ++++++++-- ergon_cli/ergon_cli/commands/run.py | 2 +- ergon_cli/ergon_cli/commands/workflow.py | 4 +- ergon_cli/ergon_cli/composition/__init__.py | 41 +++--- ergon_cli/ergon_cli/main.py | 42 +++++- ergon_cli/ergon_cli/onboarding/profile.py | 24 ++-- 8 files changed, 183 insertions(+), 146 deletions(-) diff --git a/ergon_cli/ergon_cli/commands/benchmark.py b/ergon_cli/ergon_cli/commands/benchmark.py index e1641edf..f9b29680 100644 --- a/ergon_cli/ergon_cli/commands/benchmark.py +++ b/ergon_cli/ergon_cli/commands/benchmark.py @@ -1,40 +1,31 @@ """Benchmark subcommand: list, run, and setup benchmarks.""" -import asyncio import json import os import sys import time import tomllib -from dataclasses import dataclass from argparse import Namespace from datetime import datetime, timezone from pathlib import Path from typing import Protocol -import inngest from e2b import Template -from ergon_core.core.json_types import JsonObject -from ergon_core.core.persistence.shared.db import ensure_db, get_session -from ergon_core.core.persistence.shared.enums import TERMINAL_RUN_STATUSES -from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.events.task_events import WorkflowStartedEvent -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service -from ergon_core.core.runtime.services.run_service import create_run -from ergon_core.core.settings import settings - -from ergon_cli.composition import build_experiment +from ergon_core.core.shared.json_types import JsonObject +from ergon_core.core.persistence.shared.db import ensure_db +from ergon_core.core.application.read_models.cohorts import experiment_cohort_service +from ergon_core.core.application.experiments.service import ( + ExperimentService, +) +from ergon_core.core.application.experiments.models import ( + ExperimentDefineRequest, + ExperimentRunRequest, +) +from ergon_core.core.shared.settings import settings + +from ergon_cli.commands.experiment import validate_explicit_runtime_choices from ergon_cli.discovery import list_benchmarks -from ergon_cli.rendering import render_run_result, render_table - - -@dataclass(frozen=True) -class ExperimentRunHandle: - run_id: object - definition_id: object - benchmark_type: str - status: str +from ergon_cli.rendering import render_table class BuildLog(Protocol): @@ -185,90 +176,52 @@ def _on_build_logs(log: BuildLog) -> None: # 7. Report print(f"\nSuccess! Template ID: {template_id} (build {build_info.build_id}, {build_time}s)") - print(f"Now run: `ergon benchmark run {slug} --worker minif2f-react --model --limit 1`") + print( + f"Now run: `ergon benchmark run {slug} --limit 1 --worker " + "--model --evaluator --sandbox " + f"{slug} --extras none`" + ) return 0 async def run_benchmark(args: Namespace) -> int: ensure_db() - - experiment = build_experiment( - benchmark_slug=args.slug, + benchmark_slug = args.slug + validation_args = Namespace( + benchmark_slug=benchmark_slug, + worker=args.worker, + evaluator=args.evaluator, + sandbox=args.sandbox, model=args.model, - worker_slug=args.worker, - evaluator_slug=args.evaluator, - workflow=args.workflow, - limit=args.limit, + extras=args.extras, ) - experiment.validate() - persisted = experiment.persist() - render_run_result(persisted) - print(f"\nExperiment persisted: {persisted.definition_id}") - + dependency_extras = validate_explicit_runtime_choices(validation_args) cohort_name = args.slug if args.cohort is None else args.cohort cohort = experiment_cohort_service.resolve_or_create( name=cohort_name, description=f"Benchmark: {args.slug} | worker: {args.worker} | evaluator: {args.evaluator}", created_by="ergon-cli", ) - print(f"\nCohort: {cohort.name} (id={cohort.id})") - - print("\nCreating run and dispatching via Inngest...") - run_handle = await _create_and_dispatch(persisted, timeout=args.timeout, cohort_id=cohort.id) - - print("\nRun completed:") - print(f" Run ID: {run_handle.run_id}") - print(f" Status: {run_handle.status}") - print(f" Benchmark: {run_handle.benchmark_type}") - return 0 if run_handle.status == "completed" else 1 - - -async def _create_and_dispatch(persisted, timeout: int = 600, cohort_id=None): - run = create_run(persisted, cohort_id=cohort_id) - print(f" Run ID: {run.id}") - - event = WorkflowStartedEvent( - run_id=run.id, - definition_id=persisted.definition_id, - ) - await inngest_client.send( - inngest.Event( - name=WorkflowStartedEvent.name, - data=event.model_dump(mode="json"), + experiment_service = ExperimentService() + defined = experiment_service.define_benchmark_experiment( + ExperimentDefineRequest( + benchmark_slug=benchmark_slug, + name=args.name, + cohort_id=cohort.id, + limit=args.limit, + sample_ids=args.sample_id or None, + default_model_target=args.model, + default_worker_team={"primary": args.worker}, + default_evaluator_slug=args.evaluator, + sandbox_slug=args.sandbox, + dependency_extras=dependency_extras, + metadata={"workflow": args.workflow, "max_questions": args.max_questions}, ) ) - print(" WorkflowStartedEvent emitted. Polling for completion...") - - start = time.time() - terminal = TERMINAL_RUN_STATUSES - poll_interval = 2.0 - - while True: - elapsed = time.time() - start - if elapsed > timeout: - print(f" TIMEOUT after {timeout}s") - return ExperimentRunHandle( - run_id=run.id, - definition_id=persisted.definition_id, - benchmark_type=persisted.benchmark_type, - status="timeout", - ) - - session = get_session() - try: - current = session.get(RunRecord, run.id) - if current and current.status in terminal: - return ExperimentRunHandle( - run_id=run.id, - definition_id=persisted.definition_id, - benchmark_type=persisted.benchmark_type, - status=current.status, - ) - status = current.status if current else "unknown" - finally: - session.close() - - mins = int(elapsed) // 60 - secs = int(elapsed) % 60 - print(f" [{mins:02d}:{secs:02d}] status={status}") - await asyncio.sleep(poll_interval) + launched = await experiment_service.run_experiment( + ExperimentRunRequest(experiment_id=defined.experiment_id) + ) + print(f"EXPERIMENT_ID={launched.experiment_id}") + for run_id in launched.run_ids: + print(f"RUN_ID={run_id}") + return 0 diff --git a/ergon_cli/ergon_cli/commands/doctor.py b/ergon_cli/ergon_cli/commands/doctor.py index a0b5dcc4..c30716bd 100644 --- a/ergon_cli/ergon_cli/commands/doctor.py +++ b/ergon_cli/ergon_cli/commands/doctor.py @@ -80,7 +80,7 @@ def _check_tcp(host: str, port: int, label: str) -> bool: def _check_database() -> bool: try: # Deferred: avoid heavy import at CLI startup - from ergon_core.core.settings import settings # type: ignore[import-untyped] + from ergon_core.core.shared.settings import settings # type: ignore[import-untyped] url = settings.database_url if url.startswith("sqlite"): @@ -104,7 +104,7 @@ def _check_database() -> bool: def _check_inngest() -> bool: try: # Deferred: avoid heavy import at CLI startup - from ergon_core.core.settings import settings # type: ignore[import-untyped] + from ergon_core.core.shared.settings import settings # type: ignore[import-untyped] base = settings.inngest_api_base_url parsed = urlparse(base) diff --git a/ergon_cli/ergon_cli/commands/experiment.py b/ergon_cli/ergon_cli/commands/experiment.py index 00727339..fd70b2d1 100644 --- a/ergon_cli/ergon_cli/commands/experiment.py +++ b/ergon_cli/ergon_cli/commands/experiment.py @@ -5,13 +5,12 @@ from uuid import UUID from ergon_core.core.persistence.shared.db import ensure_db -from ergon_core.core.runtime.services.cohort_service import experiment_cohort_service -from ergon_core.core.runtime.services.experiment_definition_service import ( - ExperimentDefinitionService, +from ergon_core.core.application.read_models.cohorts import experiment_cohort_service +from ergon_core.core.application.experiments.service import ( + ExperimentService, ) -from ergon_core.core.runtime.services.experiment_launch_service import ExperimentLaunchService -from ergon_core.core.runtime.services.experiment_read_service import ExperimentReadService -from ergon_core.core.runtime.services.experiment_schemas import ( +from ergon_core.core.application.read_models.experiments import ExperimentReadService +from ergon_core.core.application.experiments.models import ( ExperimentDefineRequest, ExperimentRunRequest, ) @@ -36,6 +35,7 @@ async def handle_experiment(args: Namespace) -> int: def handle_experiment_define(args: Namespace) -> int: _ensure_cli_logging() ensure_db() + dependency_extras = validate_explicit_runtime_choices(args) cohort_id = None if args.cohort: cohort = experiment_cohort_service.resolve_or_create( @@ -55,9 +55,14 @@ def handle_experiment_define(args: Namespace) -> int: default_model_target=args.model, default_worker_team={"primary": args.worker}, default_evaluator_slug=args.evaluator, - metadata={"workflow": args.workflow, "max_questions": args.max_questions}, + sandbox_slug=args.sandbox, + dependency_extras=dependency_extras, + metadata={ + "workflow": args.workflow, + "max_questions": args.max_questions, + }, ) - result = ExperimentDefinitionService().define_benchmark_experiment(request) + result = ExperimentService().define_benchmark_experiment(request) logger.info("EXPERIMENT_ID=%s", result.experiment_id) if result.cohort_id is not None: logger.info("COHORT_ID=%s", result.cohort_id) @@ -69,7 +74,7 @@ def handle_experiment_define(args: Namespace) -> int: async def handle_experiment_run(args: Namespace) -> int: _ensure_cli_logging() ensure_db() - result = await ExperimentLaunchService().run_experiment( + result = await ExperimentService().run_experiment( ExperimentRunRequest( experiment_id=UUID(args.experiment_id), timeout_seconds=args.timeout, @@ -143,3 +148,49 @@ def handle_experiment_list(args: Namespace) -> int: def _ensure_cli_logging() -> None: if not logging.getLogger().handlers: logging.basicConfig(level=logging.INFO, format="%(message)s") + + +def validate_explicit_runtime_choices(args: Namespace) -> tuple[str, ...]: + """Validate all explicit runtime choices before defining an experiment.""" + benchmarks, workers, evaluators, sandbox_managers, model_backends = _load_registry() + + if args.benchmark_slug not in benchmarks: + raise ValueError(f"Unknown benchmark slug: {args.benchmark_slug}") + if args.worker not in workers: + raise ValueError(f"Unknown worker slug: {args.worker}") + if args.evaluator not in evaluators: + raise ValueError(f"Unknown evaluator slug: {args.evaluator}") + if args.sandbox not in sandbox_managers: + raise ValueError(f"Unknown sandbox slug: {args.sandbox}") + + model_prefix = str(args.model).split(":", 1)[0] + if model_prefix not in model_backends: + raise ValueError(f"Unknown model backend prefix: {model_prefix}") + + extras = tuple(args.extras) + if extras == ("none",): + return extras + + benchmark_cls = benchmarks[args.benchmark_slug] + allowed_extras = set(getattr(benchmark_cls.onboarding_deps, "extras", ())) + unknown_extras = [extra for extra in extras if extra not in allowed_extras] + if unknown_extras: + raise ValueError( + f"Unknown extras for benchmark {args.benchmark_slug!r}: {unknown_extras}; " + f"allowed extras: {sorted(allowed_extras) or ['none']}" + ) + return extras + + +def _load_registry(): + from ergon_builtins.registry import MODEL_BACKENDS, register_builtins + from ergon_core.api.registry import registry + + register_builtins(registry) + return ( + registry.benchmarks, + registry.workers, + registry.evaluators, + registry.sandbox_managers, + MODEL_BACKENDS, + ) diff --git a/ergon_cli/ergon_cli/commands/run.py b/ergon_cli/ergon_cli/commands/run.py index 803f7fa2..3b6ff766 100644 --- a/ergon_cli/ergon_cli/commands/run.py +++ b/ergon_cli/ergon_cli/commands/run.py @@ -5,7 +5,7 @@ from ergon_core.core.persistence.shared.db import ensure_db, get_session from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.services.run_service import cancel_run as do_cancel +from ergon_core.core.application.workflows.runs import cancel_run as do_cancel from sqlmodel import select from ergon_cli.rendering import render_table diff --git a/ergon_cli/ergon_cli/commands/workflow.py b/ergon_cli/ergon_cli/commands/workflow.py index 4560a1ee..e9bcc8f6 100644 --- a/ergon_cli/ergon_cli/commands/workflow.py +++ b/ergon_cli/ergon_cli/commands/workflow.py @@ -9,10 +9,10 @@ from typing import cast from uuid import UUID -from ergon_core.core.json_types import JsonObject +from ergon_core.core.shared.json_types import JsonObject from ergon_core.core.persistence.shared.enums import RunResourceKind from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.runtime.services.workflow_service import WorkflowService +from ergon_core.core.application.workflows.service import WorkflowService from pydantic import BaseModel from sqlmodel import Session diff --git a/ergon_cli/ergon_cli/composition/__init__.py b/ergon_cli/ergon_cli/composition/__init__.py index 77689ba3..ade6c176 100644 --- a/ergon_cli/ergon_cli/composition/__init__.py +++ b/ergon_cli/ergon_cli/composition/__init__.py @@ -2,9 +2,9 @@ import os -from ergon_core.api.experiment import Experiment -from ergon_core.api.worker_spec import WorkerSpec - +from ergon_core.api.registry import registry +from ergon_core.core.domain.experiments import Experiment, WorkerSpec +from ergon_builtins.registry import register_builtins def build_experiment( benchmark_slug: str, @@ -14,28 +14,26 @@ def build_experiment( workflow: str = "single", limit: int | None = None, ) -> Experiment: + + register_builtins(registry) benchmark_registry_restore: tuple[dict[str, object], dict[str, object | None]] | None = None if os.environ.get("ENABLE_SMOKE_FIXTURES", os.environ.get("ENABLE_TEST_HARNESS")) == "1": # Host-side real-LLM canaries use the same test-support smoke fixtures as the # API container, but production CLI paths do not load them unless the # flag is explicitly enabled. - from ergon_builtins.registry import BENCHMARKS, SANDBOX_MANAGERS - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures + from tests.fixtures.smoke_components import register_smoke_fixtures slugs = ("researchrubrics", "minif2f", "swebench-verified") benchmark_registry_restore = ( - {slug: BENCHMARKS[slug] for slug in slugs if slug in BENCHMARKS}, - {slug: SANDBOX_MANAGERS.get(slug) for slug in slugs}, + {slug: registry.benchmarks[slug] for slug in slugs if slug in registry.benchmarks}, + {slug: registry.sandbox_managers.get(slug) for slug in slugs}, ) register_smoke_fixtures() - # Deferred: CLI startup cost - from ergon_builtins.registry import BENCHMARKS, EVALUATORS, WORKERS - - if worker_slug not in WORKERS: + if worker_slug not in registry.workers: raise KeyError(worker_slug) - benchmark_cls = BENCHMARKS[benchmark_slug] - evaluator_cls = EVALUATORS[evaluator_slug] + benchmark_cls = registry.require_benchmark(benchmark_slug) + evaluator_cls = registry.require_evaluator(evaluator_slug) benchmark = _construct_benchmark(benchmark_cls, workflow=workflow, limit=limit) evaluator = evaluator_cls(name="evaluator") @@ -97,11 +95,8 @@ def _build_smoke_experiment( at runtime via ``ExperimentDefinitionWorker`` lookup in ``task_execution_service._prepare_graph_native``. """ - # reason: optional heavy dependency; imported only while building smoke compositions. - from ergon_builtins.registry import WORKERS - # reason: optional test-support smoke fixtures; imported only for smoke compositions. - from ergon_core.test_support.smoke_fixtures.criteria.timing import ( + from tests.fixtures.smoke_components.criteria.timing import ( SmokePostRootTimingRubric, ) @@ -128,7 +123,7 @@ def _build_smoke_experiment( # hook imported will see the ``ConfigurationError`` from the # runtime (clearer stack) than a composition-time # ``KeyError: {env}-smoke-leaf``. - leaf_slugs = [slug for slug in leaf_slugs if slug in WORKERS] + leaf_slugs = [slug for slug in leaf_slugs if slug in registry.workers] workers: dict[str, WorkerSpec] = {parent_name: parent_spec} for leaf_slug in leaf_slugs: @@ -185,7 +180,7 @@ def _build_researchrubrics_workflow_experiment( evaluators = {"default": evaluator} if "post-root" in benchmark.evaluator_requirements(): # reason: optional test-support smoke fixtures; imported only when requested. - from ergon_core.test_support.smoke_fixtures.criteria.timing import ( + from tests.fixtures.smoke_components.criteria.timing import ( SmokePostRootTimingRubric, ) @@ -225,11 +220,9 @@ def _restore_benchmark_registry( benchmarks: dict[str, object], sandbox_managers: dict[str, object | None], ) -> None: - from ergon_builtins.registry import BENCHMARKS, SANDBOX_MANAGERS - - BENCHMARKS.update(benchmarks) + registry.benchmarks.update(benchmarks) for slug, manager_cls in sandbox_managers.items(): if manager_cls is None: - SANDBOX_MANAGERS.pop(slug, None) + registry.sandbox_managers.pop(slug, None) else: - SANDBOX_MANAGERS[slug] = manager_cls + registry.sandbox_managers[slug] = manager_cls diff --git a/ergon_cli/ergon_cli/main.py b/ergon_cli/ergon_cli/main.py index 21bc74d0..36a7d244 100644 --- a/ergon_cli/ergon_cli/main.py +++ b/ergon_cli/ergon_cli/main.py @@ -14,6 +14,12 @@ from ergon_cli.commands.train import handle_train from ergon_cli.commands.worker import handle_worker from ergon_cli.commands.workflow import handle_workflow +from ergon_builtins.registry import register_builtins +from ergon_core.api.registry import registry + + +def register_default_components() -> None: + register_builtins(registry) def build_parser() -> argparse.ArgumentParser: @@ -30,6 +36,32 @@ def build_parser() -> argparse.ArgumentParser: setup_parser.add_argument( "--force", action="store_true", help="Rebuild even if the template already exists" ) + bench_run = bench_sub.add_parser( + "run", help="Define and run a benchmark experiment with explicit runtime choices" + ) + bench_run.add_argument("slug", help="Benchmark slug") + bench_sample_group = bench_run.add_mutually_exclusive_group(required=True) + bench_sample_group.add_argument("--limit", type=int, default=None, help="Number of samples") + bench_sample_group.add_argument( + "--sample-id", + action="append", + default=None, + help="Specific benchmark sample id; can be repeated", + ) + bench_run.add_argument("--name", default=None, help="Experiment name") + bench_run.add_argument("--cohort", default=None, help="Optional cohort/project folder") + bench_run.add_argument("--worker", required=True, help="Primary worker slug") + bench_run.add_argument("--model", required=True, help="Primary model target") + bench_run.add_argument("--evaluator", required=True, help="Evaluator slug") + bench_run.add_argument("--sandbox", required=True, help="Sandbox manager slug") + bench_run.add_argument( + "--extras", + action="append", + required=True, + help="Required dependency extra; repeat for multiple extras or pass 'none'", + ) + bench_run.add_argument("--workflow", default="single", help="Workflow variant") + bench_run.add_argument("--max-questions", type=int, default=10, help="Max questions workers can ask") experiment = sub.add_parser("experiment", help="Experiment lifecycle") experiment_sub = experiment.add_subparsers(dest="experiment_action") @@ -47,7 +79,14 @@ def build_parser() -> argparse.ArgumentParser: experiment_define.add_argument("--cohort", default=None, help="Optional cohort/project folder") experiment_define.add_argument("--worker", required=True, help="Primary worker slug") experiment_define.add_argument("--model", required=True, help="Primary model target") - experiment_define.add_argument("--evaluator", default=None, help="Optional evaluator slug") + experiment_define.add_argument("--evaluator", required=True, help="Evaluator slug") + experiment_define.add_argument("--sandbox", required=True, help="Sandbox manager slug") + experiment_define.add_argument( + "--extras", + action="append", + required=True, + help="Required dependency extra; repeat for multiple extras or pass 'none'", + ) experiment_define.add_argument("--workflow", default="single", help="Workflow variant") experiment_define.add_argument( "--max-questions", @@ -188,6 +227,7 @@ def build_parser() -> argparse.ArgumentParser: async def _main(argv: list[str] | None = None) -> int: + register_default_components() parser = build_parser() args = parser.parse_args(argv) diff --git a/ergon_cli/ergon_cli/onboarding/profile.py b/ergon_cli/ergon_cli/onboarding/profile.py index 4f090acf..f52a116d 100644 --- a/ergon_cli/ergon_cli/onboarding/profile.py +++ b/ergon_cli/ergon_cli/onboarding/profile.py @@ -2,9 +2,11 @@ from enum import Enum -from ergon_core.api.benchmark_deps import BenchmarkDeps from pydantic import BaseModel, Field +from ergon_builtins.registry import register_builtins +from ergon_core.api.registry import registry + class LLMProvider(str, Enum): OPENAI = "openai" @@ -46,9 +48,8 @@ class OnboardProfile(BaseModel): def required_keys(self) -> dict[str, str]: """Return {env_var: human_reason} derived purely from user choices.""" - # reason: deferred import avoids circular dep at CLI startup; registry - # depends on ergon_builtins which depends on ergon_core. - from ergon_builtins.registry import BENCHMARKS + register_builtins(registry) + benchmarks = registry.benchmarks result: dict[str, str] = {} @@ -56,12 +57,12 @@ def required_keys(self) -> dict[str, str]: env_var = PROVIDER_KEY_MAP[provider] result[env_var] = f"{provider.value} API access" - if any(BENCHMARKS[b].onboarding_deps.e2b for b in self.benchmarks if b in BENCHMARKS): + if any(benchmarks[b].onboarding_deps.e2b for b in self.benchmarks if b in benchmarks): result["E2B_API_KEY"] = "Sandboxed code execution for selected benchmarks" for b in self.benchmarks: - if b in BENCHMARKS: - for k in BENCHMARKS[b].onboarding_deps.optional_keys: + if b in benchmarks: + for k in benchmarks[b].onboarding_deps.optional_keys: result.setdefault(k, f"Optional for {b}") if self.gpu_provider and self.gpu_provider != GPUProvider.LOCAL: @@ -72,14 +73,13 @@ def required_keys(self) -> dict[str, str]: def required_extras(self) -> list[str]: """Pip extras to install based on choices.""" - # reason: deferred import avoids circular dep at CLI startup; registry - # depends on ergon_builtins which depends on ergon_core. - from ergon_builtins.registry import BENCHMARKS + register_builtins(registry) + benchmarks = registry.benchmarks extras: set[str] = set() for b in self.benchmarks: - if b in BENCHMARKS: - for e in BENCHMARKS[b].onboarding_deps.extras: + if b in benchmarks: + for e in benchmarks[b].onboarding_deps.extras: extras.add(e) if self.training: extras.add("ergon-infra[training]") From db5157958ed86129ba902f06a6d416c9089e8a6f Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:46 +0100 Subject: [PATCH 65/66] test: move smoke fixtures into shared test fixtures Made-with: Cursor --- .../test_support/smoke_fixtures/__init__.py | 103 ------------------ .../workers/researchrubrics_smoke_sadpath.py | 13 --- tests/__init__.py | 1 + tests/fixtures/smoke_components/__init__.py | 82 ++++++++++++++ .../fixtures/smoke_components}/benchmarks.py | 44 ++++++-- .../smoke_components}/criteria/__init__.py | 0 .../criteria/minif2f_smoke.py | 31 +++--- .../criteria/researchrubrics_smoke.py | 29 +++-- .../criteria/smoke_rubrics.py | 15 +-- .../criteria/swebench_smoke.py | 31 +++--- .../smoke_components}/criteria/timing.py | 13 +-- .../fixtures/smoke_components}/sandbox.py | 7 +- .../smoke_components}/smoke_base/__init__.py | 0 .../smoke_components}/smoke_base/constants.py | 0 .../smoke_base/criterion_base.py | 52 ++++----- .../smoke_components}/smoke_base/leaf_base.py | 39 +++---- .../smoke_components}/smoke_base/recursive.py | 35 +++--- .../smoke_components}/smoke_base/sadpath.py | 7 +- .../smoke_components}/smoke_base/subworker.py | 2 +- .../smoke_base/worker_base.py | 41 +++---- .../smoke_components}/workers/__init__.py | 0 .../workers/minif2f_smoke.py | 20 +--- .../workers/researchrubrics_smoke.py | 20 +--- .../workers/researchrubrics_smoke_sadpath.py | 7 ++ .../workers/swebench_smoke.py | 20 +--- .../smoke_base/test_always_fail_subworker.py | 2 +- .../smoke_base/test_e2e_smoke_driver_pairs.py | 24 ++++ .../test_leaf_sends_completion_message.py | 20 ++-- .../unit/smoke_base/test_minif2f_criterion.py | 32 +++--- .../test_recursive_smoke_worker_routing.py | 6 +- .../smoke_base/test_registry_smoke_entries.py | 80 ++++++++------ .../test_researchrubrics_criterion.py | 36 +++--- .../smoke_base/test_sadpath_worker_routing.py | 6 +- .../test_smoke_criterion_completed.py | 10 +- .../smoke_base/test_smoke_criterion_probe.py | 12 +- .../smoke_base/test_smoke_criterion_shape.py | 10 +- .../smoke_base/test_smoke_sandbox_manager.py | 36 +++--- .../test_smoke_worker_base_final.py | 2 +- .../test_smoke_worker_spec_for_override.py | 2 +- .../smoke_base/test_swebench_criterion.py | 40 +++---- 40 files changed, 444 insertions(+), 486 deletions(-) delete mode 100644 ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py delete mode 100644 ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py create mode 100644 tests/fixtures/smoke_components/__init__.py rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/benchmarks.py (76%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/criteria/__init__.py (100%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/criteria/minif2f_smoke.py (80%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/criteria/researchrubrics_smoke.py (82%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/criteria/smoke_rubrics.py (84%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/criteria/swebench_smoke.py (81%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/criteria/timing.py (72%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/sandbox.py (97%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/smoke_base/__init__.py (100%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/smoke_base/constants.py (100%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/smoke_base/criterion_base.py (87%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/smoke_base/leaf_base.py (83%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/smoke_base/recursive.py (85%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/smoke_base/sadpath.py (91%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/smoke_base/subworker.py (95%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/smoke_base/worker_base.py (85%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/workers/__init__.py (100%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/workers/minif2f_smoke.py (83%) rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/workers/researchrubrics_smoke.py (84%) create mode 100644 tests/fixtures/smoke_components/workers/researchrubrics_smoke_sadpath.py rename {ergon_core/ergon_core/test_support/smoke_fixtures => tests/fixtures/smoke_components}/workers/swebench_smoke.py (81%) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py b/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py deleted file mode 100644 index 24e84df5..00000000 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/__init__.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Test-only worker / criterion registration hook. - -``register_smoke_fixtures`` registers the per-env canonical-smoke workers, -leaves, and criteria into the process-level ``WORKERS`` / ``EVALUATORS`` -dicts from ``ergon_builtins.registry``. Production CLI paths do not import -``tests/``, so registrations here are confined to explicitly gated test -runtimes. - -Phase C (this commit) adds the researchrubrics happy + sad-path rows. -Phase D adds minif2f and swebench-verified. Idempotent: calling twice -is a no-op (``dict`` assignment is the mechanism). - -See docs/superpowers/plans/test-refactor/01-fixtures.md §2.7. -""" - -import os - -from ergon_builtins.registry import BENCHMARKS, EVALUATORS, SANDBOX_MANAGERS, WORKERS -from ergon_core.test_support.smoke_fixtures.benchmarks import ( - MiniF2FSmokeBenchmark, - ResearchRubricsSmokeBenchmark, - SweBenchSmokeBenchmark, -) -from ergon_core.test_support.smoke_fixtures.criteria.smoke_rubrics import ( - MiniF2FSmokeRubric, - ResearchRubricsSmokeRubric, - SweBenchSmokeRubric, -) -from ergon_core.test_support.smoke_fixtures.criteria.timing import SmokePostRootTimingRubric -from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager -from ergon_core.test_support.smoke_fixtures.workers.minif2f_smoke import ( - MiniF2FFailingLeafWorker, - MiniF2FRecursiveSmokeWorker, - MiniF2FSadPathSmokeWorker, - MiniF2FSmokeLeafWorker, - MiniF2FSmokeWorker, -) -from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke import ( - ResearchRubricsFailingLeafWorker, - ResearchRubricsRecursiveSmokeWorker, - ResearchRubricsSadPathSmokeWorker, - ResearchRubricsSmokeLeafWorker, - ResearchRubricsSmokeWorker, -) -from ergon_core.test_support.smoke_fixtures.workers.swebench_smoke import ( - SweBenchFailingLeafWorker, - SweBenchRecursiveSmokeWorker, - SweBenchSadPathSmokeWorker, - SweBenchSmokeLeafWorker, - SweBenchSmokeWorker, -) - - -def register_smoke_fixtures() -> None: - """Register the per-env smoke worker + criterion-rubric slugs. - - Called on import (below) so the fixtures are available by the time - the e2e pytest session starts executing test modules. Idempotent: - calling multiple times reassigns the same dict entries without - side-effects. - - Note: evaluator slugs map to ``Rubric`` subclasses that wrap a - single smoke criterion — the CLI composition layer expects - ``EVALUATORS`` values to satisfy the ``Evaluator`` interface - (``.criteria_for`` / ``.aggregate_task``), which bare ``Criterion`` - subclasses don't provide. See ``criteria/smoke_rubrics.py``. - """ - if os.environ.get("ENABLE_TEST_HARNESS") == "1": - # Production benchmark loaders fetch external datasets. The smoke - # harness owns its benchmark roots so CI stays deterministic and offline. - BENCHMARKS[ResearchRubricsSmokeBenchmark.type_slug] = ResearchRubricsSmokeBenchmark - BENCHMARKS[MiniF2FSmokeBenchmark.type_slug] = MiniF2FSmokeBenchmark - BENCHMARKS[SweBenchSmokeBenchmark.type_slug] = SweBenchSmokeBenchmark - SANDBOX_MANAGERS[ResearchRubricsSmokeBenchmark.type_slug] = SmokeSandboxManager - SANDBOX_MANAGERS[MiniF2FSmokeBenchmark.type_slug] = SmokeSandboxManager - SANDBOX_MANAGERS[SweBenchSmokeBenchmark.type_slug] = SmokeSandboxManager - - # ResearchRubrics happy-path - WORKERS[ResearchRubricsSmokeWorker.type_slug] = ResearchRubricsSmokeWorker - WORKERS[ResearchRubricsSmokeLeafWorker.type_slug] = ResearchRubricsSmokeLeafWorker - WORKERS[ResearchRubricsRecursiveSmokeWorker.type_slug] = ResearchRubricsRecursiveSmokeWorker - EVALUATORS[ResearchRubricsSmokeRubric.type_slug] = ResearchRubricsSmokeRubric - EVALUATORS[SmokePostRootTimingRubric.type_slug] = SmokePostRootTimingRubric - - # ResearchRubrics sad-path (paired with the happy run in each smoke cohort) - WORKERS[ResearchRubricsSadPathSmokeWorker.type_slug] = ResearchRubricsSadPathSmokeWorker - WORKERS[ResearchRubricsFailingLeafWorker.type_slug] = ResearchRubricsFailingLeafWorker - - # MiniF2F happy + sad-path - WORKERS[MiniF2FSmokeWorker.type_slug] = MiniF2FSmokeWorker - WORKERS[MiniF2FSmokeLeafWorker.type_slug] = MiniF2FSmokeLeafWorker - WORKERS[MiniF2FRecursiveSmokeWorker.type_slug] = MiniF2FRecursiveSmokeWorker - WORKERS[MiniF2FSadPathSmokeWorker.type_slug] = MiniF2FSadPathSmokeWorker - WORKERS[MiniF2FFailingLeafWorker.type_slug] = MiniF2FFailingLeafWorker - EVALUATORS[MiniF2FSmokeRubric.type_slug] = MiniF2FSmokeRubric - - # SWE-Bench Verified happy + sad-path - WORKERS[SweBenchSmokeWorker.type_slug] = SweBenchSmokeWorker - WORKERS[SweBenchSmokeLeafWorker.type_slug] = SweBenchSmokeLeafWorker - WORKERS[SweBenchRecursiveSmokeWorker.type_slug] = SweBenchRecursiveSmokeWorker - WORKERS[SweBenchSadPathSmokeWorker.type_slug] = SweBenchSadPathSmokeWorker - WORKERS[SweBenchFailingLeafWorker.type_slug] = SweBenchFailingLeafWorker - EVALUATORS[SweBenchSmokeRubric.type_slug] = SweBenchSmokeRubric diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py b/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py deleted file mode 100644 index 90e3b9f4..00000000 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke_sadpath.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Compatibility imports for the ResearchRubrics sad-path fixture.""" - -from ergon_core.test_support.smoke_fixtures.smoke_base.sadpath import AlwaysFailSubworker -from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke import ( - ResearchRubricsFailingLeafWorker, - ResearchRubricsSadPathSmokeWorker, -) - -__all__ = [ - "AlwaysFailSubworker", - "ResearchRubricsFailingLeafWorker", - "ResearchRubricsSadPathSmokeWorker", -] diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..c3b0330f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Shared black-box test helpers and suites.""" diff --git a/tests/fixtures/smoke_components/__init__.py b/tests/fixtures/smoke_components/__init__.py new file mode 100644 index 00000000..bf52a10d --- /dev/null +++ b/tests/fixtures/smoke_components/__init__.py @@ -0,0 +1,82 @@ +"""Test-only smoke component registration.""" + +import os + +from ergon_core.api.registry import ComponentRegistry, registry +from tests.fixtures.smoke_components.benchmarks import ( + MiniF2FSmokeBenchmark, + ResearchRubricsSmokeBenchmark, + SweBenchSmokeBenchmark, +) +from tests.fixtures.smoke_components.criteria.smoke_rubrics import ( + MiniF2FSmokeRubric, + ResearchRubricsSmokeRubric, + SweBenchSmokeRubric, +) +from tests.fixtures.smoke_components.criteria.timing import SmokePostRootTimingRubric +from tests.fixtures.smoke_components.sandbox import SmokeSandboxManager +from tests.fixtures.smoke_components.workers.minif2f_smoke import ( + MiniF2FFailingLeafWorker, + MiniF2FRecursiveSmokeWorker, + MiniF2FSadPathSmokeWorker, + MiniF2FSmokeLeafWorker, + MiniF2FSmokeWorker, +) +from tests.fixtures.smoke_components.workers.researchrubrics_smoke import ( + ResearchRubricsFailingLeafWorker, + ResearchRubricsRecursiveSmokeWorker, + ResearchRubricsSadPathSmokeWorker, + ResearchRubricsSmokeLeafWorker, + ResearchRubricsSmokeWorker, +) +from tests.fixtures.smoke_components.workers.swebench_smoke import ( + SweBenchFailingLeafWorker, + SweBenchRecursiveSmokeWorker, + SweBenchSadPathSmokeWorker, + SweBenchSmokeLeafWorker, + SweBenchSmokeWorker, +) + + +def register_smoke_fixtures(target: ComponentRegistry = registry) -> None: + """Register smoke-only benchmark, worker, evaluator, and sandbox slugs.""" + + if os.environ.get("ENABLE_TEST_HARNESS") == "1": + # Production benchmark loaders fetch external datasets. The smoke + # harness owns its benchmark roots so CI stays deterministic and offline. + target.benchmarks[ResearchRubricsSmokeBenchmark.type_slug] = ResearchRubricsSmokeBenchmark + target.benchmarks[MiniF2FSmokeBenchmark.type_slug] = MiniF2FSmokeBenchmark + target.benchmarks[SweBenchSmokeBenchmark.type_slug] = SweBenchSmokeBenchmark + target.sandbox_managers[ResearchRubricsSmokeBenchmark.type_slug] = SmokeSandboxManager + target.sandbox_managers[MiniF2FSmokeBenchmark.type_slug] = SmokeSandboxManager + target.sandbox_managers[SweBenchSmokeBenchmark.type_slug] = SmokeSandboxManager + + # ResearchRubrics happy-path + target.register_worker(ResearchRubricsSmokeWorker.type_slug, ResearchRubricsSmokeWorker) + target.register_worker(ResearchRubricsSmokeLeafWorker.type_slug, ResearchRubricsSmokeLeafWorker) + target.register_worker( + ResearchRubricsRecursiveSmokeWorker.type_slug, + ResearchRubricsRecursiveSmokeWorker, + ) + target.register_evaluator(ResearchRubricsSmokeRubric) + target.register_evaluator(SmokePostRootTimingRubric) + + # ResearchRubrics sad-path (paired with the happy run in each smoke cohort) + target.register_worker(ResearchRubricsSadPathSmokeWorker.type_slug, ResearchRubricsSadPathSmokeWorker) + target.register_worker(ResearchRubricsFailingLeafWorker.type_slug, ResearchRubricsFailingLeafWorker) + + # MiniF2F happy + sad-path + target.register_worker(MiniF2FSmokeWorker.type_slug, MiniF2FSmokeWorker) + target.register_worker(MiniF2FSmokeLeafWorker.type_slug, MiniF2FSmokeLeafWorker) + target.register_worker(MiniF2FRecursiveSmokeWorker.type_slug, MiniF2FRecursiveSmokeWorker) + target.register_worker(MiniF2FSadPathSmokeWorker.type_slug, MiniF2FSadPathSmokeWorker) + target.register_worker(MiniF2FFailingLeafWorker.type_slug, MiniF2FFailingLeafWorker) + target.register_evaluator(MiniF2FSmokeRubric) + + # SWE-Bench Verified happy + sad-path + target.register_worker(SweBenchSmokeWorker.type_slug, SweBenchSmokeWorker) + target.register_worker(SweBenchSmokeLeafWorker.type_slug, SweBenchSmokeLeafWorker) + target.register_worker(SweBenchRecursiveSmokeWorker.type_slug, SweBenchRecursiveSmokeWorker) + target.register_worker(SweBenchSadPathSmokeWorker.type_slug, SweBenchSadPathSmokeWorker) + target.register_worker(SweBenchFailingLeafWorker.type_slug, SweBenchFailingLeafWorker) + target.register_evaluator(SweBenchSmokeRubric) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py b/tests/fixtures/smoke_components/benchmarks.py similarity index 76% rename from ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py rename to tests/fixtures/smoke_components/benchmarks.py index 53e82a35..7d682753 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/benchmarks.py +++ b/tests/fixtures/smoke_components/benchmarks.py @@ -4,34 +4,56 @@ publication, evaluation, and dashboard rendering. It should not depend on network access or private Hugging Face credentials to materialize the root task, so these fixtures replace the production benchmark loaders only when -``ergon_core.test_support.smoke_fixtures`` is imported by the test harness. +``tests.fixtures.smoke_components`` is imported by the test harness. """ from collections.abc import Mapping, Sequence from typing import ClassVar -from ergon_builtins.benchmarks.minif2f.task_schemas import MiniF2FTaskPayload -from ergon_builtins.benchmarks.researchrubrics.task_schemas import ResearchRubricsTaskPayload -from ergon_builtins.benchmarks.swebench_verified.task_schemas import SWEBenchTaskPayload -from ergon_core.api.benchmark import Benchmark -from ergon_core.api.benchmark_deps import BenchmarkDeps -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.core.json_types import JsonObject +from ergon_core.api.benchmark import Benchmark, BenchmarkRequirements, EmptyTaskPayload, Task +from ergon_core.core.shared.json_types import JsonObject from pydantic import BaseModel +class ResearchRubricsTaskPayload(BaseModel): + sample_id: str + domain: str + prompt: str + rubrics: list[JsonObject] + + +class MiniF2FTaskPayload(BaseModel): + name: str + informal_statement: str + formal_statement: str + header: str + + +class SWEBenchTaskPayload(BaseModel): + instance_id: str + repo: str + base_commit: str + version: str + problem_statement: str + hints_text: str + fail_to_pass: list[str] + pass_to_pass: list[str] + environment_setup_commit: str + test_patch: str + + class _SingleTaskSmokeBenchmark(Benchmark): """Base class for smoke benchmarks that expose one deterministic task.""" - onboarding_deps: ClassVar[BenchmarkDeps] = BenchmarkDeps(e2b=True) + onboarding_deps: ClassVar[BenchmarkRequirements] = BenchmarkRequirements(e2b=True) task_slug: ClassVar[str] task_description: ClassVar[str] task_payload: ClassVar[JsonObject] = {} task_payload_model = EmptyTaskPayload - def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[BaseModel]]]: + def build_instances(self) -> Mapping[str, Sequence[Task[BaseModel]]]: payload = self.task_payload_model.model_validate(self.task_payload) - task = BenchmarkTask[BaseModel]( + task = Task[BaseModel]( task_slug=self.task_slug, instance_key="default", description=self.task_description, diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/__init__.py b/tests/fixtures/smoke_components/criteria/__init__.py similarity index 100% rename from ergon_core/ergon_core/test_support/smoke_fixtures/criteria/__init__.py rename to tests/fixtures/smoke_components/criteria/__init__.py diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/minif2f_smoke.py b/tests/fixtures/smoke_components/criteria/minif2f_smoke.py similarity index 80% rename from ergon_core/ergon_core/test_support/smoke_fixtures/criteria/minif2f_smoke.py rename to tests/fixtures/smoke_components/criteria/minif2f_smoke.py index 65e90e80..e777fcff 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/minif2f_smoke.py +++ b/tests/fixtures/smoke_components/criteria/minif2f_smoke.py @@ -11,11 +11,11 @@ from pathlib import Path -from ergon_core.api.errors import CriteriaCheckError -from ergon_core.api.evaluation_context import EvaluationContext +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.errors import CriterionCheckError from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution -from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from tests.fixtures.smoke_components.smoke_base.criterion_base import SmokeCriterionBase from sqlmodel import col, desc, select HEALTH_THEOREM = """\ @@ -36,7 +36,7 @@ async def _verify_env_content(self, context, children, probes) -> None: ).all() ] if not exec_ids: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: no RunTaskExecution rows", ) resource = session.exec( @@ -53,43 +53,40 @@ async def _verify_env_content(self, context, children, probes) -> None: .limit(1), ).first() if resource is None: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: no proof_*.lean RunResource", ) text = Path(resource.file_path).read_bytes().decode("utf-8") if "theorem smoke_trivial" not in text: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: lean source missing theorem marker", ) if ":=" not in text: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: lean source missing proof term `:=`", ) - async def _verify_sandbox_setup(self, context: EvaluationContext) -> None: + async def _verify_sandbox_setup(self, context: CriterionContext) -> None: """Compile a trivial theorem. Proves Lean + elan wrapper are wired up. ``trivial`` proof term avoids Mathlib dependency so this runs fast even on a cold toolchain.""" - if context.runtime is None: - raise CriteriaCheckError( + if not context.has_runtime: + raise CriterionCheckError( "minif2f sandbox health: CriterionRuntime not injected", ) - await context.runtime.ensure_sandbox() - await context.runtime.write_file( + await context.ensure_sandbox() + await context.write_file( "/tmp/smoke_health.lean", HEALTH_THEOREM.encode("utf-8"), ) - result = await context.runtime.run_command( + result = await context.run_command( "lean --check /tmp/smoke_health.lean", timeout=60, ) if result.exit_code != 0: stdout = ("" if result.stdout is None else result.stdout)[:300] stderr = ("" if result.stderr is None else result.stderr)[:300] - raise CriteriaCheckError( + raise CriterionCheckError( f"minif2f sandbox health failed: lean --check " f"exit={result.exit_code} stdout={stdout!r} stderr={stderr!r}", ) - - -__all__ = ["MiniF2FSmokeCriterion"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/researchrubrics_smoke.py b/tests/fixtures/smoke_components/criteria/researchrubrics_smoke.py similarity index 82% rename from ergon_core/ergon_core/test_support/smoke_fixtures/criteria/researchrubrics_smoke.py rename to tests/fixtures/smoke_components/criteria/researchrubrics_smoke.py index 9c0f1091..ae07a22a 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/researchrubrics_smoke.py +++ b/tests/fixtures/smoke_components/criteria/researchrubrics_smoke.py @@ -19,11 +19,11 @@ from pathlib import Path -from ergon_core.api.errors import CriteriaCheckError -from ergon_core.api.evaluation_context import EvaluationContext +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.errors import CriterionCheckError from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution -from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from tests.fixtures.smoke_components.smoke_base.criterion_base import SmokeCriterionBase from sqlmodel import col, desc, select @@ -43,7 +43,7 @@ async def _verify_env_content(self, context, children, probes) -> None: ).all() ] if not exec_ids: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: no RunTaskExecution rows", ) resource = session.exec( @@ -60,27 +60,27 @@ async def _verify_env_content(self, context, children, probes) -> None: .limit(1), ).first() if resource is None: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: no report_*.md RunResource", ) body = Path(resource.file_path).read_bytes() if not body.startswith(b"# Research report"): - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: report missing `# Research report` header", ) if len(body.strip()) < 20: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: report body too short ({len(body)} bytes)", ) - async def _verify_sandbox_setup(self, context: EvaluationContext) -> None: + async def _verify_sandbox_setup(self, context: CriterionContext) -> None: """Trivial env probe: bash + coreutils + /tmp writable.""" - if context.runtime is None: - raise CriteriaCheckError( + if not context.has_runtime: + raise CriterionCheckError( "researchrubrics sandbox health: CriterionRuntime not injected", ) - await context.runtime.ensure_sandbox() - result = await context.runtime.run_command( + await context.ensure_sandbox() + result = await context.run_command( "set -e; " "echo '# hello world' > /tmp/smoke_health.md && " "test \"$(wc -l < /tmp/smoke_health.md)\" = '1' && " @@ -89,10 +89,7 @@ async def _verify_sandbox_setup(self, context: EvaluationContext) -> None: ) stdout = "" if result.stdout is None else result.stdout if result.exit_code != 0 or "OK" not in stdout: - raise CriteriaCheckError( + raise CriterionCheckError( f"researchrubrics sandbox health failed: " f"exit={result.exit_code} stdout={stdout[:200]!r}", ) - - -__all__ = ["ResearchRubricsSmokeCriterion"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py b/tests/fixtures/smoke_components/criteria/smoke_rubrics.py similarity index 84% rename from ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py rename to tests/fixtures/smoke_components/criteria/smoke_rubrics.py index c871e533..e3847b93 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/smoke_rubrics.py +++ b/tests/fixtures/smoke_components/criteria/smoke_rubrics.py @@ -17,12 +17,12 @@ from collections.abc import Mapping from typing import Any, ClassVar -from ergon_core.api.evaluator import Rubric -from ergon_core.test_support.smoke_fixtures.criteria.minif2f_smoke import MiniF2FSmokeCriterion -from ergon_core.test_support.smoke_fixtures.criteria.researchrubrics_smoke import ( +from ergon_core.api.rubric import Rubric +from tests.fixtures.smoke_components.criteria.minif2f_smoke import MiniF2FSmokeCriterion +from tests.fixtures.smoke_components.criteria.researchrubrics_smoke import ( ResearchRubricsSmokeCriterion, ) -from ergon_core.test_support.smoke_fixtures.criteria.swebench_smoke import SweBenchSmokeCriterion +from tests.fixtures.smoke_components.criteria.swebench_smoke import SweBenchSmokeCriterion class ResearchRubricsSmokeRubric(Rubric): @@ -77,10 +77,3 @@ def __init__( criteria=[SweBenchSmokeCriterion(slug="swebench-smoke")], metadata=metadata, ) - - -__all__ = [ - "MiniF2FSmokeRubric", - "ResearchRubricsSmokeRubric", - "SweBenchSmokeRubric", -] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/swebench_smoke.py b/tests/fixtures/smoke_components/criteria/swebench_smoke.py similarity index 81% rename from ergon_core/ergon_core/test_support/smoke_fixtures/criteria/swebench_smoke.py rename to tests/fixtures/smoke_components/criteria/swebench_smoke.py index d2f41903..94018145 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/swebench_smoke.py +++ b/tests/fixtures/smoke_components/criteria/swebench_smoke.py @@ -14,11 +14,11 @@ import ast from pathlib import Path -from ergon_core.api.errors import CriteriaCheckError -from ergon_core.api.evaluation_context import EvaluationContext +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.errors import CriterionCheckError from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution -from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from tests.fixtures.smoke_components.smoke_base.criterion_base import SmokeCriterionBase from sqlmodel import col, desc, select HEALTH_PY = """\ @@ -41,7 +41,7 @@ async def _verify_env_content(self, context, children, probes) -> None: ).all() ] if not exec_ids: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: no RunTaskExecution rows", ) resource = session.exec( @@ -58,46 +58,43 @@ async def _verify_env_content(self, context, children, probes) -> None: .limit(1), ).first() if resource is None: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: no patch_*.py RunResource", ) source = Path(resource.file_path).read_bytes().decode("utf-8") try: tree = ast.parse(source) except SyntaxError as err: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: python AST parse failed: {err}", ) from err func_names = { node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) } if "add" not in func_names: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: expected function `add`, got {sorted(func_names)}", ) - async def _verify_sandbox_setup(self, context: EvaluationContext) -> None: + async def _verify_sandbox_setup(self, context: CriterionContext) -> None: """Python 3.10+ present + pytest importable.""" - if context.runtime is None: - raise CriteriaCheckError( + if not context.has_runtime: + raise CriterionCheckError( "swebench sandbox health: CriterionRuntime not injected", ) - await context.runtime.ensure_sandbox() - await context.runtime.write_file( + await context.ensure_sandbox() + await context.write_file( "/tmp/smoke_health.py", HEALTH_PY.encode("utf-8"), ) - result = await context.runtime.run_command( + result = await context.run_command( "python /tmp/smoke_health.py && python -c 'import pytest; print(pytest.__version__)'", timeout=15, ) stdout = "" if result.stdout is None else result.stdout if result.exit_code != 0 or "HEALTH_OK" not in stdout: stderr = ("" if result.stderr is None else result.stderr)[:300] - raise CriteriaCheckError( + raise CriterionCheckError( f"swebench sandbox health failed: exit={result.exit_code} " f"stdout={stdout[:300]!r} stderr={stderr!r}", ) - - -__all__ = ["SweBenchSmokeCriterion"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py b/tests/fixtures/smoke_components/criteria/timing.py similarity index 72% rename from ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py rename to tests/fixtures/smoke_components/criteria/timing.py index afa30ab5..1530f7f1 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/criteria/timing.py +++ b/tests/fixtures/smoke_components/criteria/timing.py @@ -3,10 +3,8 @@ from collections.abc import Mapping from typing import Any, ClassVar -from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.evaluator import Rubric -from ergon_core.api.results import CriterionResult +from ergon_core.api.criterion import Criterion, CriterionContext, CriterionOutcome +from ergon_core.api.rubric import Rubric class SmokePostRootTimingCriterion(Criterion): @@ -14,8 +12,8 @@ class SmokePostRootTimingCriterion(Criterion): type_slug: ClassVar[str] = "smoke-post-root-timing-criterion" - async def evaluate(self, context: EvaluationContext) -> CriterionResult: - return CriterionResult( + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: + return CriterionOutcome( slug=self.slug, name=self.slug, score=1.0, @@ -41,6 +39,3 @@ def __init__( criteria=[SmokePostRootTimingCriterion(slug="smoke-post-root-timing")], metadata=metadata, ) - - -__all__ = ["SmokePostRootTimingCriterion", "SmokePostRootTimingRubric"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/sandbox.py b/tests/fixtures/smoke_components/sandbox.py similarity index 97% rename from ergon_core/ergon_core/test_support/smoke_fixtures/sandbox.py rename to tests/fixtures/smoke_components/sandbox.py index 65a28726..3ec824f6 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/sandbox.py +++ b/tests/fixtures/smoke_components/sandbox.py @@ -12,8 +12,8 @@ from typing import cast from uuid import UUID -from ergon_core.core.sandbox.manager import AsyncSandbox, BaseSandboxManager -from ergon_core.core.settings import settings +from ergon_core.core.infrastructure.sandbox.manager import AsyncSandbox, BaseSandboxManager +from ergon_core.core.shared.settings import settings from pydantic import BaseModel _SMOKE_SANDBOX_PREFIX = "smoke-sandbox-" @@ -208,6 +208,3 @@ async def terminate(self, task_id: UUID, reason: str = "completed") -> None: def smoke_uses_local_sandbox() -> bool: return os.environ.get("ENABLE_TEST_HARNESS") == "1" and settings.e2b_api_key is not None - - -__all__ = ["SmokeSandbox", "SmokeSandboxManager", "smoke_uses_local_sandbox"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/__init__.py b/tests/fixtures/smoke_components/smoke_base/__init__.py similarity index 100% rename from ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/__init__.py rename to tests/fixtures/smoke_components/smoke_base/__init__.py diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/constants.py b/tests/fixtures/smoke_components/smoke_base/constants.py similarity index 100% rename from ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/constants.py rename to tests/fixtures/smoke_components/smoke_base/constants.py diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py b/tests/fixtures/smoke_components/smoke_base/criterion_base.py similarity index 87% rename from ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py rename to tests/fixtures/smoke_components/smoke_base/criterion_base.py index e0078af1..629b01a8 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/criterion_base.py +++ b/tests/fixtures/smoke_components/smoke_base/criterion_base.py @@ -10,8 +10,8 @@ task's sandbox via ``context.runtime.run_command(...)``; proves the toolchain is healthy at evaluation time. -Both hooks raise ``CriteriaCheckError`` to surface as a failed -``CriterionResult``; anything else propagates as a bug. +Both hooks raise ``CriterionCheckError`` to surface as a failed +``CriterionOutcome``; anything else propagates as a bug. Topology + slug set is sourced from ``constants.EXPECTED_SUBTASK_SLUGS``. @@ -26,14 +26,13 @@ from uuid import UUID from ergon_core.api.criterion import Criterion -from ergon_core.api.errors import CriteriaCheckError -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult +from ergon_core.api.criterion import CriterionContext, CriterionOutcome +from ergon_core.api.errors import CriterionCheckError from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.graph.status_conventions import COMPLETED from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.telemetry.models import RunResource, RunTaskExecution -from ergon_core.test_support.smoke_fixtures.smoke_base.constants import EXPECTED_SUBTASK_SLUGS +from tests.fixtures.smoke_components.smoke_base.constants import EXPECTED_SUBTASK_SLUGS from pydantic import BaseModel from sqlmodel import col, desc, select @@ -72,7 +71,7 @@ class SmokeCriterionBase(Criterion): hooks only. """ - async def evaluate(self, context: EvaluationContext) -> CriterionResult: + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: try: # 1. Artifact-side checks (no sandbox; reads blob storage only) children = await self._pull_children(context) @@ -91,8 +90,8 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: # produced. No fresh sandbox acquisition — zero extra # E2B cost. await self._verify_sandbox_setup(context) - except CriteriaCheckError as e: - return CriterionResult( + except CriterionCheckError as e: + return CriterionOutcome( slug=self.slug, name=self.slug, score=0.0, @@ -100,7 +99,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: weight=self.weight, feedback=f"smoke criterion failed: {e}", ) - return CriterionResult( + return CriterionOutcome( slug=self.slug, name=self.slug, score=1.0, @@ -113,7 +112,7 @@ async def evaluate(self, context: EvaluationContext) -> CriterionResult: async def _pull_children( self, - context: EvaluationContext, + context: CriterionContext, ) -> list[RunGraphNode]: """Return direct-child ``RunGraphNode`` rows of the parent task. @@ -125,7 +124,7 @@ async def _pull_children( with get_session() as session: parent_exec = session.get(RunTaskExecution, context.execution_id) if parent_exec is None or parent_exec.node_id is None: - raise CriteriaCheckError( + raise CriterionCheckError( f"no RunTaskExecution / node_id for execution_id={context.execution_id}", ) children = list( @@ -163,7 +162,7 @@ async def _artifact_children( async def _pull_probe_results( self, - context: EvaluationContext, + context: CriterionContext, children: list[RunGraphNode], ) -> dict[UUID, ProbeResult]: """Return ``{child_node_id: {"exit_code": int, "stdout": str}}``. @@ -184,7 +183,7 @@ async def _pull_probe_results( ).all() ] if not exec_ids: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: no RunTaskExecution rows for node", ) resource = session.exec( @@ -201,20 +200,20 @@ async def _pull_probe_results( .limit(1), ).first() if resource is None: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: no probe_*.json RunResource row", ) blob_bytes = Path(resource.file_path).read_bytes() try: parsed = json.loads(blob_bytes) except json.JSONDecodeError as err: - raise CriteriaCheckError( + raise CriterionCheckError( f"{child.task_slug}: probe JSON invalid: {err}", ) from err results[child.id] = ProbeResult.model_validate(parsed) return results - # -- structural checks (raise CriteriaCheckError → failed result) -------- + # -- structural checks (raise CriterionCheckError -> failed result) ------- def _check_graph_shape( self, @@ -223,7 +222,7 @@ def _check_graph_shape( actual = {c.task_slug for c in children} expected = set(EXPECTED_SUBTASK_SLUGS) if actual != expected: - raise CriteriaCheckError( + raise CriterionCheckError( f"graph shape mismatch: actual={sorted(actual)} expected={sorted(expected)}", ) @@ -233,7 +232,7 @@ def _check_children_completed( ) -> None: for c in children: if c.status != COMPLETED: - raise CriteriaCheckError( + raise CriterionCheckError( f"child {c.task_slug} not completed (status={c.status!r})", ) @@ -248,7 +247,7 @@ def _check_probes_succeeded( code = probe.exit_code if code != 0: stdout = "" if probe.stdout is None else probe.stdout - raise CriteriaCheckError( + raise CriterionCheckError( f"probe for {slug} exited {code}, stdout={stdout!r}", ) @@ -256,20 +255,20 @@ def _check_probes_succeeded( async def _verify_env_content( self, - context: EvaluationContext, + context: CriterionContext, children: list[RunGraphNode], probes: dict[UUID, ProbeResult], ) -> None: """Subclass hook: read artifacts and check env-specific file shape. - Raise ``CriteriaCheckError`` when content does not match + Raise ``CriterionCheckError`` when content does not match expectations; any other exception propagates as a bug. """ raise NotImplementedError( "Subclasses must implement env-specific content verification", ) - async def _verify_sandbox_setup(self, context: EvaluationContext) -> None: + async def _verify_sandbox_setup(self, context: CriterionContext) -> None: """Subclass hook: run a trivial env-specific command in the parent task's sandbox to prove the toolchain is healthy. @@ -277,11 +276,11 @@ async def _verify_sandbox_setup(self, context: EvaluationContext) -> None: DI API — criteria never call ``AsyncSandbox.connect`` directly): if context.runtime is None: - raise CriteriaCheckError("no CriterionRuntime injected") + raise CriterionCheckError("no CriterionRuntime injected") await context.runtime.ensure_sandbox() result = await context.runtime.run_command("", timeout=20) if result.exit_code != 0: - raise CriteriaCheckError( + raise CriterionCheckError( f" health probe failed: exit={result.exit_code} " f"stdout={(result.stdout or '')[:200]!r}", ) @@ -289,6 +288,3 @@ async def _verify_sandbox_setup(self, context: EvaluationContext) -> None: raise NotImplementedError( "Subclasses must implement env-specific sandbox health check", ) - - -__all__ = ["CompletionChild", "ProbeChild", "ProbeResult", "SlugChild", "SmokeCriterionBase"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py b/tests/fixtures/smoke_components/smoke_base/leaf_base.py similarity index 83% rename from ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py rename to tests/fixtures/smoke_components/smoke_base/leaf_base.py index 4a26d505..19000f77 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/leaf_base.py +++ b/tests/fixtures/smoke_components/smoke_base/leaf_base.py @@ -24,19 +24,19 @@ from typing import ClassVar from uuid import UUID -from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.core.generation import AssistantTextPart, ContextPartChunk -from ergon_core.api.results import WorkerOutput +from ergon_core.api import Task, Worker, WorkerContext, WorkerStreamItem +from ergon_core.api.worker import WorkerOutput +from ergon_core.core.domain.generation.context_parts import AssistantTextPart, ContextPartChunk from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.sandbox.instrumentation import InstrumentedSandbox -from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest -from ergon_core.core.runtime.services.communication_service import ( +from ergon_core.core.infrastructure.sandbox.instrumentation import InstrumentedSandbox +from ergon_core.core.application.communication.models import CreateMessageRequest +from ergon_core.core.application.communication.service import ( communication_service, ) -from ergon_core.core.settings import settings -from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager -from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import ( +from ergon_core.core.shared.settings import settings +from tests.fixtures.smoke_components.sandbox import SmokeSandboxManager +from tests.fixtures.smoke_components.smoke_base.subworker import ( SmokeSubworker, SubworkerResult, ) @@ -68,10 +68,10 @@ def __init__( async def execute( self, - task: BenchmarkTask, + task: Task, *, context: WorkerContext, - ) -> AsyncGenerator[ContextPartChunk, None]: + ) -> AsyncGenerator[WorkerStreamItem, None]: node_hex = context.node_id.hex[:8] if context.node_id else "unknown" # --- Turn 1: attaching + starting --------------------------------- @@ -111,16 +111,12 @@ async def execute( ), ) - def get_output(self, context: WorkerContext) -> WorkerOutput: - r = self._last_result - if r is None: - return WorkerOutput(output="", success=False, metadata={"error": "no_result"}) - return WorkerOutput( - output=r.probe_stdout, - success=r.probe_exit_code == 0, + yield WorkerOutput( + output=result.probe_stdout, + success=result.probe_exit_code == 0, metadata={ - "probe_exit_code": r.probe_exit_code, - "file_path": r.file_path, + "probe_exit_code": result.probe_exit_code, + "file_path": result.file_path, }, ) @@ -170,6 +166,3 @@ def _lookup_task_slug(node_id: UUID | None) -> str: with get_session() as session: node = session.get(RunGraphNode, node_id) return node.task_slug if node is not None else f"node-{node_id.hex[:8]}" - - -__all__ = ["BaseSmokeLeafWorker"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py b/tests/fixtures/smoke_components/smoke_base/recursive.py similarity index 85% rename from ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py rename to tests/fixtures/smoke_components/smoke_base/recursive.py index 7631cf2a..591d9a02 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/recursive.py +++ b/tests/fixtures/smoke_components/smoke_base/recursive.py @@ -11,18 +11,18 @@ from typing import ClassVar from uuid import UUID -from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.core.generation import AssistantTextPart, ContextPartChunk -from ergon_core.api.results import WorkerOutput +from ergon_core.api import Task, Worker, WorkerContext, WorkerStreamItem +from ergon_core.api.worker import WorkerOutput +from ergon_core.core.domain.generation.context_parts import AssistantTextPart, ContextPartChunk from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, NodeId, RunId, TaskSlug -from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest -from ergon_core.core.runtime.services.communication_service import communication_service -from ergon_core.core.runtime.services.task_inspection_service import TaskInspectionService -from ergon_core.core.runtime.services.task_management_dto import PlanSubtasksCommand, SubtaskSpec -from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from ergon_core.core.application.communication.models import CreateMessageRequest +from ergon_core.core.application.communication.service import communication_service +from ergon_core.core.application.tasks.inspection import TaskInspectionService +from ergon_core.core.application.tasks.models import PlanSubtasksCommand, SubtaskSpec +from ergon_core.core.application.tasks.management import TaskManagementService NESTED_LINE_SLUGS: tuple[str, ...] = ("l_2_a", "l_2_b") NESTED_SUBTASK_GRAPH: tuple[tuple[str, tuple[str, ...], str], ...] = ( @@ -43,10 +43,10 @@ def __init__(self, **kwargs) -> None: async def execute( self, - task: BenchmarkTask, + task: Task, *, context: WorkerContext, - ) -> AsyncGenerator[ContextPartChunk, None]: + ) -> AsyncGenerator[WorkerStreamItem, None]: if context.node_id is None: raise ValueError(f"{type(self).__name__} requires context.node_id") @@ -110,19 +110,20 @@ async def execute( ), ) - def get_output(self, context: WorkerContext) -> WorkerOutput: non_completed = { slug: status for slug, status in self._last_child_statuses.items() if status != "completed" } if non_completed: - return WorkerOutput( + yield WorkerOutput( output=f"nested children did not all complete: {non_completed}", success=False, metadata={"child_statuses": self._last_child_statuses}, ) - return WorkerOutput( + return + + yield WorkerOutput( output="nested smoke recursion completed", success=True, metadata={"child_statuses": self._last_child_statuses}, @@ -165,11 +166,3 @@ def _spec_for(self, slug, deps, desc): assigned_worker_slug=AssignedWorkerSlug(worker_slug), depends_on=[TaskSlug(d) for d in deps], ) - - -__all__ = [ - "NESTED_LINE_SLUGS", - "NESTED_SUBTASK_GRAPH", - "RecursiveSmokeWorkerBase", - "RecursiveSmokeWorkerMixin", -] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py b/tests/fixtures/smoke_components/smoke_base/sadpath.py similarity index 91% rename from ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py rename to tests/fixtures/smoke_components/smoke_base/sadpath.py index c419f8f9..0471a81b 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/sadpath.py +++ b/tests/fixtures/smoke_components/smoke_base/sadpath.py @@ -10,8 +10,8 @@ from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] from ergon_core.api import WorkerContext from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug -from ergon_core.core.runtime.services.task_management_dto import SubtaskSpec -from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult +from ergon_core.core.application.tasks.models import SubtaskSpec +from tests.fixtures.smoke_components.smoke_base.subworker import SubworkerResult class AlwaysFailSubworker: @@ -77,6 +77,3 @@ async def _send_completion_message( result: SubworkerResult, ) -> None: return None - - -__all__ = ["AlwaysFailSubworker", "FailingSmokeLeafMixin", "SadPathSmokeWorkerMixin"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/subworker.py b/tests/fixtures/smoke_components/smoke_base/subworker.py similarity index 95% rename from ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/subworker.py rename to tests/fixtures/smoke_components/smoke_base/subworker.py index 9fccef3b..f8781da0 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/subworker.py +++ b/tests/fixtures/smoke_components/smoke_base/subworker.py @@ -21,7 +21,7 @@ from typing import Protocol, runtime_checkable -from ergon_core.core.sandbox.manager import AsyncSandbox +from ergon_core.core.infrastructure.sandbox.manager import AsyncSandbox from pydantic import BaseModel diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py b/tests/fixtures/smoke_components/smoke_base/worker_base.py similarity index 85% rename from ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py rename to tests/fixtures/smoke_components/smoke_base/worker_base.py index c603d2ad..92eea3ba 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/smoke_base/worker_base.py +++ b/tests/fixtures/smoke_components/smoke_base/worker_base.py @@ -17,9 +17,9 @@ from collections.abc import AsyncGenerator from typing import ClassVar, final -from ergon_core.api import BenchmarkTask, Worker, WorkerContext -from ergon_core.core.generation import AssistantTextPart, ContextPartChunk -from ergon_core.api.results import WorkerOutput +from ergon_core.api import Task, Worker, WorkerContext, WorkerStreamItem +from ergon_core.api.worker import WorkerOutput +from ergon_core.core.domain.generation.context_parts import AssistantTextPart, ContextPartChunk from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.types import ( @@ -28,17 +28,17 @@ RunId, TaskSlug, ) -from ergon_core.core.runtime.services.task_inspection_service import ( +from ergon_core.core.application.tasks.inspection import ( TaskInspectionService, ) -from ergon_core.core.runtime.services.task_management_dto import ( +from ergon_core.core.application.tasks.models import ( PlanSubtasksCommand, SubtaskSpec, ) -from ergon_core.core.runtime.services.task_management_service import ( +from ergon_core.core.application.tasks.management import ( TaskManagementService, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.constants import SUBTASK_GRAPH +from tests.fixtures.smoke_components.smoke_base.constants import SUBTASK_GRAPH _CHILD_WAIT_TERMINAL_STATUSES = TERMINAL_STATUSES | {"blocked"} @@ -68,10 +68,10 @@ def __init__(self, **kwargs) -> None: @final async def execute( self, - task: BenchmarkTask, + task: Task, *, context: WorkerContext, - ) -> AsyncGenerator[ContextPartChunk, None]: + ) -> AsyncGenerator[WorkerStreamItem, None]: if context.node_id is None: raise ValueError(f"{type(self).__name__} requires context.node_id") @@ -114,12 +114,13 @@ async def execute( ) # --- Turn 3: awaiting children (terminal) ------------------------- + waiting_message = ( + f"{type(self).__name__}: awaiting 9 children -- " + "runtime will mark parent COMPLETED once wait_all resolves" + ) yield ContextPartChunk( part=AssistantTextPart( - content=( - f"{type(self).__name__}: awaiting 9 children — " - "runtime will mark parent COMPLETED once wait_all resolves" - ), + content=waiting_message, ), ) @@ -140,19 +141,24 @@ async def execute( break await asyncio.sleep(2) - def get_output(self, context: WorkerContext) -> WorkerOutput: non_completed = { slug: status for slug, status in self._last_child_statuses.items() if status != "completed" } if non_completed: - return WorkerOutput( + yield WorkerOutput( output=f"child tasks did not all complete: {non_completed}", success=False, metadata={"child_statuses": self._last_child_statuses}, ) - return super().get_output(context) + return + + yield WorkerOutput( + output=waiting_message, + success=True, + metadata={"child_statuses": self._last_child_statuses}, + ) def _spec_for( self, @@ -174,6 +180,3 @@ def _spec_for( assigned_worker_slug=AssignedWorkerSlug(self.leaf_slug), depends_on=[TaskSlug(d) for d in deps], ) - - -__all__ = ["SmokeWorkerBase"] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/__init__.py b/tests/fixtures/smoke_components/workers/__init__.py similarity index 100% rename from ergon_core/ergon_core/test_support/smoke_fixtures/workers/__init__.py rename to tests/fixtures/smoke_components/workers/__init__.py diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py b/tests/fixtures/smoke_components/workers/minif2f_smoke.py similarity index 83% rename from ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py rename to tests/fixtures/smoke_components/workers/minif2f_smoke.py index 3656d098..a7eaea2f 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/minif2f_smoke.py +++ b/tests/fixtures/smoke_components/workers/minif2f_smoke.py @@ -10,18 +10,18 @@ import json from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] -from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker -from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import ( +from tests.fixtures.smoke_components.smoke_base.leaf_base import BaseSmokeLeafWorker +from tests.fixtures.smoke_components.smoke_base.recursive import ( RecursiveSmokeWorkerBase, RecursiveSmokeWorkerMixin, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.sadpath import ( +from tests.fixtures.smoke_components.smoke_base.sadpath import ( AlwaysFailSubworker, FailingSmokeLeafMixin, SadPathSmokeWorkerMixin, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult -from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase +from tests.fixtures.smoke_components.smoke_base.subworker import SubworkerResult +from tests.fixtures.smoke_components.smoke_base.worker_base import SmokeWorkerBase # Trivial Lean source used by every leaf. Deterministic; small enough to # parse in <1s even on a cold Lean toolchain. @@ -92,13 +92,3 @@ class MiniF2FSadPathSmokeWorker(SadPathSmokeWorkerMixin, SmokeWorkerBase): type_slug = "minif2f-sadpath-smoke-worker" leaf_slug = "minif2f-smoke-leaf" FAILING_LEAF_SLUG = "minif2f-smoke-leaf-failing" - - -__all__ = [ - "MiniF2FFailingLeafWorker", - "MiniF2FRecursiveSmokeWorker", - "MiniF2FSadPathSmokeWorker", - "MiniF2FSmokeLeafWorker", - "MiniF2FSmokeWorker", - "MiniF2FSubworker", -] diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py b/tests/fixtures/smoke_components/workers/researchrubrics_smoke.py similarity index 84% rename from ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py rename to tests/fixtures/smoke_components/workers/researchrubrics_smoke.py index debe8e82..29b7fa14 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/researchrubrics_smoke.py +++ b/tests/fixtures/smoke_components/workers/researchrubrics_smoke.py @@ -17,18 +17,18 @@ import json from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] -from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker -from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import ( +from tests.fixtures.smoke_components.smoke_base.leaf_base import BaseSmokeLeafWorker +from tests.fixtures.smoke_components.smoke_base.recursive import ( RecursiveSmokeWorkerBase, RecursiveSmokeWorkerMixin, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.sadpath import ( +from tests.fixtures.smoke_components.smoke_base.sadpath import ( AlwaysFailSubworker, FailingSmokeLeafMixin, SadPathSmokeWorkerMixin, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult -from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase +from tests.fixtures.smoke_components.smoke_base.subworker import SubworkerResult +from tests.fixtures.smoke_components.smoke_base.worker_base import SmokeWorkerBase class ResearchRubricsSmokeWorker(RecursiveSmokeWorkerMixin, SmokeWorkerBase): @@ -98,13 +98,3 @@ class ResearchRubricsSadPathSmokeWorker(SadPathSmokeWorkerMixin, SmokeWorkerBase type_slug = "researchrubrics-sadpath-smoke-worker" leaf_slug = "researchrubrics-smoke-leaf" FAILING_LEAF_SLUG = "researchrubrics-smoke-leaf-failing" - - -__all__ = [ - "ResearchRubricsFailingLeafWorker", - "ResearchRubricsRecursiveSmokeWorker", - "ResearchRubricsSadPathSmokeWorker", - "ResearchRubricsSmokeLeafWorker", - "ResearchRubricsSmokeWorker", - "ResearchRubricsSubworker", -] diff --git a/tests/fixtures/smoke_components/workers/researchrubrics_smoke_sadpath.py b/tests/fixtures/smoke_components/workers/researchrubrics_smoke_sadpath.py new file mode 100644 index 00000000..0a2d4e40 --- /dev/null +++ b/tests/fixtures/smoke_components/workers/researchrubrics_smoke_sadpath.py @@ -0,0 +1,7 @@ +"""Compatibility imports for the ResearchRubrics sad-path fixture.""" + +from tests.fixtures.smoke_components.smoke_base.sadpath import AlwaysFailSubworker +from tests.fixtures.smoke_components.workers.researchrubrics_smoke import ( + ResearchRubricsFailingLeafWorker, + ResearchRubricsSadPathSmokeWorker, +) diff --git a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py b/tests/fixtures/smoke_components/workers/swebench_smoke.py similarity index 81% rename from ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py rename to tests/fixtures/smoke_components/workers/swebench_smoke.py index ad877881..2300294b 100644 --- a/ergon_core/ergon_core/test_support/smoke_fixtures/workers/swebench_smoke.py +++ b/tests/fixtures/smoke_components/workers/swebench_smoke.py @@ -8,18 +8,18 @@ import json from e2b_code_interpreter import AsyncSandbox # type: ignore[import-untyped] -from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker -from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import ( +from tests.fixtures.smoke_components.smoke_base.leaf_base import BaseSmokeLeafWorker +from tests.fixtures.smoke_components.smoke_base.recursive import ( RecursiveSmokeWorkerBase, RecursiveSmokeWorkerMixin, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.sadpath import ( +from tests.fixtures.smoke_components.smoke_base.sadpath import ( AlwaysFailSubworker, FailingSmokeLeafMixin, SadPathSmokeWorkerMixin, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult -from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase +from tests.fixtures.smoke_components.smoke_base.subworker import SubworkerResult +from tests.fixtures.smoke_components.smoke_base.worker_base import SmokeWorkerBase PY_SOURCE = """\ def add(a, b): @@ -90,13 +90,3 @@ class SweBenchSadPathSmokeWorker(SadPathSmokeWorkerMixin, SmokeWorkerBase): type_slug = "swebench-sadpath-smoke-worker" leaf_slug = "swebench-smoke-leaf" FAILING_LEAF_SLUG = "swebench-smoke-leaf-failing" - - -__all__ = [ - "SweBenchFailingLeafWorker", - "SweBenchRecursiveSmokeWorker", - "SweBenchSadPathSmokeWorker", - "SweBenchSmokeLeafWorker", - "SweBenchSmokeWorker", - "SweBenchSubworker", -] diff --git a/tests/unit/smoke_base/test_always_fail_subworker.py b/tests/unit/smoke_base/test_always_fail_subworker.py index bdd02bb8..8e626d28 100644 --- a/tests/unit/smoke_base/test_always_fail_subworker.py +++ b/tests/unit/smoke_base/test_always_fail_subworker.py @@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke_sadpath import ( +from tests.fixtures.smoke_components.workers.researchrubrics_smoke_sadpath import ( AlwaysFailSubworker, ) diff --git a/tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py b/tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py index 9994e4d3..17294e0d 100644 --- a/tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py +++ b/tests/unit/smoke_base/test_e2e_smoke_driver_pairs.py @@ -42,3 +42,27 @@ def test_e2e_smoke_driver_builds_happy_sad_pairs( ("happy", happy_worker, criterion), ("sad", sad_worker, criterion), ] + + +def test_build_cohort_payload_includes_explicit_runtime_choices() -> None: + submit = importlib.import_module("tests.e2e._submit") + + payload = submit.build_cohort_payload( + benchmark_slug="minif2f", + slots=[("minif2f-smoke-worker", "minif2f-smoke-criterion")], + cohort_key="ci-smoke-minif2f", + sandbox_slug="minif2f", + dependency_extras=("none",), + model="openai:gpt-4o", + ) + + assert payload["benchmark_slug"] == "minif2f" + assert payload["sandbox_slug"] == "minif2f" + assert payload["dependency_extras"] == ["none"] + assert payload["model"] == "openai:gpt-4o" + assert payload["slots"] == [ + { + "worker_slug": "minif2f-smoke-worker", + "evaluator_slug": "minif2f-smoke-criterion", + } + ] diff --git a/tests/unit/smoke_base/test_leaf_sends_completion_message.py b/tests/unit/smoke_base/test_leaf_sends_completion_message.py index d51ce270..30e75b95 100644 --- a/tests/unit/smoke_base/test_leaf_sends_completion_message.py +++ b/tests/unit/smoke_base/test_leaf_sends_completion_message.py @@ -9,12 +9,12 @@ from uuid import uuid4 import pytest -from ergon_core.api import BenchmarkTask +from ergon_core.api import Task from ergon_core.core.persistence.shared.types import AssignedWorkerSlug -from ergon_core.core.sandbox.manager import AsyncSandbox -from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest -from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker -from ergon_core.test_support.smoke_fixtures.smoke_base.subworker import SubworkerResult +from ergon_core.core.infrastructure.sandbox.manager import AsyncSandbox +from ergon_core.core.application.communication.models import CreateMessageRequest +from tests.fixtures.smoke_components.smoke_base.leaf_base import BaseSmokeLeafWorker +from tests.fixtures.smoke_components.smoke_base.subworker import SubworkerResult class _IdleSubworker: @@ -60,7 +60,7 @@ def _patch_session_with_task_slug(monkeypatch, slug: str) -> None: cm.__enter__ = MagicMock(return_value=session) cm.__exit__ = MagicMock(return_value=False) monkeypatch.setattr( - "ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base.get_session", + "tests.fixtures.smoke_components.smoke_base.leaf_base.get_session", lambda: cm, ) @@ -79,7 +79,7 @@ async def _record(request: CreateMessageRequest) -> MagicMock: return MagicMock() monkeypatch.setattr( - "ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base.communication_service.save_message", + "tests.fixtures.smoke_components.smoke_base.leaf_base.communication_service.save_message", AsyncMock(side_effect=_record), ) _patch_session_with_task_slug(monkeypatch, "l_2") @@ -113,12 +113,12 @@ async def test_send_completion_message_not_called_when_subworker_raises( """_send_completion_message must not be called when subworker.work() raises.""" save_mock = AsyncMock() monkeypatch.setattr( - "ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base.communication_service.save_message", + "tests.fixtures.smoke_components.smoke_base.leaf_base.communication_service.save_message", save_mock, ) # Mock sandbox reconnect so execute() doesn't need a real sandbox. monkeypatch.setattr( - "ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base.SmokeSandboxManager.reconnect", + "tests.fixtures.smoke_components.smoke_base.leaf_base.SmokeSandboxManager.reconnect", AsyncMock(return_value=MagicMock(sandbox_id="smoke-sandbox-unit")), ) @@ -131,7 +131,7 @@ class _FailingLeaf(BaseSmokeLeafWorker): subworker_cls = _FailingSubworker leaf = _FailingLeaf(name="unit-test", model=None, task_id=uuid4(), sandbox_id="sbx-unit") - task = BenchmarkTask(task_slug="l_fail", instance_key="default", description="x") + task = Task(task_slug="l_fail", instance_key="default", description="x") with pytest.raises(RuntimeError, match="sad-path"): async for _ in leaf.execute(task, context=_context()): diff --git a/tests/unit/smoke_base/test_minif2f_criterion.py b/tests/unit/smoke_base/test_minif2f_criterion.py index 87e38a6a..e066336f 100644 --- a/tests/unit/smoke_base/test_minif2f_criterion.py +++ b/tests/unit/smoke_base/test_minif2f_criterion.py @@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from ergon_core.api.errors import CriteriaCheckError -from ergon_core.test_support.smoke_fixtures.criteria.minif2f_smoke import MiniF2FSmokeCriterion +from ergon_core.api.errors import CriterionCheckError +from tests.fixtures.smoke_components.criteria.minif2f_smoke import MiniF2FSmokeCriterion def _crit() -> MiniF2FSmokeCriterion: @@ -14,20 +14,20 @@ def _crit() -> MiniF2FSmokeCriterion: @pytest.mark.asyncio async def test_sandbox_setup_passes_when_lean_compiles() -> None: ctx = MagicMock() - ctx.runtime = MagicMock() - ctx.runtime.ensure_sandbox = AsyncMock(return_value=None) - ctx.runtime.write_file = AsyncMock(return_value=None) - ctx.runtime.run_command = AsyncMock( + ctx.has_runtime = True + ctx.ensure_sandbox = AsyncMock(return_value=None) + ctx.write_file = AsyncMock(return_value=None) + ctx.run_command = AsyncMock( return_value=MagicMock(exit_code=0, stdout="", stderr=""), ) await _crit()._verify_sandbox_setup(ctx) - ctx.runtime.write_file.assert_awaited_once() - path, content = ctx.runtime.write_file.await_args.args + ctx.write_file.assert_awaited_once() + path, content = ctx.write_file.await_args.args assert path == "/tmp/smoke_health.lean" assert b"theorem health_check" in content - cmd = ctx.runtime.run_command.await_args.args[0] + cmd = ctx.run_command.await_args.args[0] assert "lean --check" in cmd assert "|| true" not in cmd, ( "criterion-side health probe must NOT soften exit code with `|| true` " @@ -38,20 +38,20 @@ async def test_sandbox_setup_passes_when_lean_compiles() -> None: @pytest.mark.asyncio async def test_sandbox_setup_raises_on_non_zero_lean_exit() -> None: ctx = MagicMock() - ctx.runtime = MagicMock() - ctx.runtime.ensure_sandbox = AsyncMock(return_value=None) - ctx.runtime.write_file = AsyncMock(return_value=None) - ctx.runtime.run_command = AsyncMock( + ctx.has_runtime = True + ctx.ensure_sandbox = AsyncMock(return_value=None) + ctx.write_file = AsyncMock(return_value=None) + ctx.run_command = AsyncMock( return_value=MagicMock(exit_code=1, stdout="error", stderr="expected 'by'"), ) - with pytest.raises(CriteriaCheckError, match=r"minif2f sandbox health failed.*exit=1"): + with pytest.raises(CriterionCheckError, match=r"minif2f sandbox health failed.*exit=1"): await _crit()._verify_sandbox_setup(ctx) @pytest.mark.asyncio async def test_sandbox_setup_raises_when_runtime_missing() -> None: ctx = MagicMock() - ctx.runtime = None - with pytest.raises(CriteriaCheckError, match="CriterionRuntime not injected"): + ctx.has_runtime = False + with pytest.raises(CriterionCheckError, match="CriterionRuntime not injected"): await _crit()._verify_sandbox_setup(ctx) diff --git a/tests/unit/smoke_base/test_recursive_smoke_worker_routing.py b/tests/unit/smoke_base/test_recursive_smoke_worker_routing.py index 1730f700..0bafadeb 100644 --- a/tests/unit/smoke_base/test_recursive_smoke_worker_routing.py +++ b/tests/unit/smoke_base/test_recursive_smoke_worker_routing.py @@ -2,11 +2,11 @@ import pytest from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug -from ergon_core.test_support.smoke_fixtures.workers.minif2f_smoke import MiniF2FSmokeWorker -from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke import ( +from tests.fixtures.smoke_components.workers.minif2f_smoke import MiniF2FSmokeWorker +from tests.fixtures.smoke_components.workers.researchrubrics_smoke import ( ResearchRubricsSmokeWorker, ) -from ergon_core.test_support.smoke_fixtures.workers.swebench_smoke import SweBenchSmokeWorker +from tests.fixtures.smoke_components.workers.swebench_smoke import SweBenchSmokeWorker @pytest.mark.parametrize( diff --git a/tests/unit/smoke_base/test_registry_smoke_entries.py b/tests/unit/smoke_base/test_registry_smoke_entries.py index 523777cc..a91bf448 100644 --- a/tests/unit/smoke_base/test_registry_smoke_entries.py +++ b/tests/unit/smoke_base/test_registry_smoke_entries.py @@ -1,4 +1,4 @@ -"""Registering ``ergon_core.test_support.smoke_fixtures`` populates smoke slugs. +"""Registering ``tests.fixtures.smoke_components`` populates smoke slugs. Phase C registers the researchrubrics happy + sad-path rows. Phase D adds minif2f and swebench-verified. This test expects exactly what's @@ -9,11 +9,17 @@ import pytest +def _registry_maps(): + from ergon_core.api.registry import registry + + return registry.workers, registry.evaluators, registry.benchmarks, registry.sandbox_managers + + def test_researchrubrics_slugs_registered() -> None: - from ergon_builtins.registry import EVALUATORS, WORKERS - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures + from tests.fixtures.smoke_components import register_smoke_fixtures register_smoke_fixtures() + workers, evaluators, _, _ = _registry_maps() expected_workers = { "researchrubrics-smoke-worker", @@ -23,80 +29,82 @@ def test_researchrubrics_slugs_registered() -> None: "researchrubrics-smoke-leaf-failing", } for slug in expected_workers: - assert slug in WORKERS, f"worker slug missing from registry: {slug}" + assert slug in workers, f"worker slug missing from registry: {slug}" - assert "researchrubrics-smoke-criterion" in EVALUATORS - assert "smoke-post-root-timing-criterion" in EVALUATORS + assert "researchrubrics-smoke-criterion" in evaluators + assert "smoke-post-root-timing-criterion" in evaluators def test_no_retired_slugs_present() -> None: - from ergon_builtins.registry import WORKERS - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures + from tests.fixtures.smoke_components import register_smoke_fixtures register_smoke_fixtures() + workers, _, _, _ = _registry_maps() retired = {"canonical-smoke"} - still_present = retired & set(WORKERS.keys()) + still_present = retired & set(workers.keys()) assert not still_present, f"Retired worker slugs still in registry: {still_present}" def test_register_is_idempotent() -> None: """Calling register_smoke_fixtures twice doesn't raise / duplicate.""" - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures + from tests.fixtures.smoke_components import register_smoke_fixtures register_smoke_fixtures() register_smoke_fixtures() def test_minif2f_slugs_registered() -> None: - from ergon_builtins.registry import EVALUATORS, WORKERS - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures + from tests.fixtures.smoke_components import register_smoke_fixtures register_smoke_fixtures() + workers, evaluators, _, _ = _registry_maps() - assert "minif2f-smoke-worker" in WORKERS - assert "minif2f-smoke-leaf" in WORKERS - assert "minif2f-smoke-recursive-worker" in WORKERS - assert "minif2f-sadpath-smoke-worker" in WORKERS - assert "minif2f-smoke-leaf-failing" in WORKERS - assert "minif2f-smoke-criterion" in EVALUATORS + assert "minif2f-smoke-worker" in workers + assert "minif2f-smoke-leaf" in workers + assert "minif2f-smoke-recursive-worker" in workers + assert "minif2f-sadpath-smoke-worker" in workers + assert "minif2f-smoke-leaf-failing" in workers + assert "minif2f-smoke-criterion" in evaluators def test_swebench_slugs_registered() -> None: - from ergon_builtins.registry import EVALUATORS, WORKERS - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures + from tests.fixtures.smoke_components import register_smoke_fixtures register_smoke_fixtures() + workers, evaluators, _, _ = _registry_maps() - assert "swebench-smoke-worker" in WORKERS - assert "swebench-smoke-leaf" in WORKERS - assert "swebench-smoke-recursive-worker" in WORKERS - assert "swebench-sadpath-smoke-worker" in WORKERS - assert "swebench-smoke-leaf-failing" in WORKERS - assert "swebench-smoke-criterion" in EVALUATORS + assert "swebench-smoke-worker" in workers + assert "swebench-smoke-leaf" in workers + assert "swebench-smoke-recursive-worker" in workers + assert "swebench-sadpath-smoke-worker" in workers + assert "swebench-smoke-leaf-failing" in workers + assert "swebench-smoke-criterion" in evaluators def test_smoke_benchmarks_are_test_owned_when_harness_enabled( monkeypatch: pytest.MonkeyPatch, ) -> None: - from ergon_builtins.registry import BENCHMARKS, SANDBOX_MANAGERS - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures - from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager + from ergon_builtins.registry import register_builtins + from ergon_core.api.registry import registry + from tests.fixtures.smoke_components import register_smoke_fixtures + from tests.fixtures.smoke_components.sandbox import SmokeSandboxManager + register_builtins(registry) slugs = ("researchrubrics", "minif2f", "swebench-verified") - original_benchmarks = {slug: BENCHMARKS[slug] for slug in slugs} - original_managers = {slug: SANDBOX_MANAGERS.get(slug) for slug in slugs} + original_benchmarks = {slug: registry.benchmarks[slug] for slug in slugs} + original_managers = {slug: registry.sandbox_managers.get(slug) for slug in slugs} monkeypatch.setenv("ENABLE_TEST_HARNESS", "1") try: register_smoke_fixtures() for slug in slugs: - assert BENCHMARKS[slug].__module__.startswith("ergon_core.test_support.smoke_fixtures") - assert SANDBOX_MANAGERS[slug] is SmokeSandboxManager + assert registry.benchmarks[slug].__module__.startswith("tests.fixtures.smoke_components") + assert registry.sandbox_managers[slug] is SmokeSandboxManager finally: - BENCHMARKS.update(original_benchmarks) + registry.benchmarks.update(original_benchmarks) for slug, manager_cls in original_managers.items(): if manager_cls is None: - SANDBOX_MANAGERS.pop(slug, None) + registry.sandbox_managers.pop(slug, None) else: - SANDBOX_MANAGERS[slug] = manager_cls + registry.sandbox_managers[slug] = manager_cls diff --git a/tests/unit/smoke_base/test_researchrubrics_criterion.py b/tests/unit/smoke_base/test_researchrubrics_criterion.py index ffc8ae7d..10431f2c 100644 --- a/tests/unit/smoke_base/test_researchrubrics_criterion.py +++ b/tests/unit/smoke_base/test_researchrubrics_criterion.py @@ -7,8 +7,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from ergon_core.api.errors import CriteriaCheckError -from ergon_core.test_support.smoke_fixtures.criteria.researchrubrics_smoke import ( +from ergon_core.api.errors import CriterionCheckError +from tests.fixtures.smoke_components.criteria.researchrubrics_smoke import ( ResearchRubricsSmokeCriterion, ) @@ -25,17 +25,17 @@ def _crit() -> ResearchRubricsSmokeCriterion: @pytest.mark.asyncio async def test_verify_sandbox_setup_passes_on_ok_output() -> None: ctx = MagicMock() - ctx.runtime = MagicMock() - ctx.runtime.ensure_sandbox = AsyncMock(return_value=None) - ctx.runtime.run_command = AsyncMock( + ctx.has_runtime = True + ctx.ensure_sandbox = AsyncMock(return_value=None) + ctx.run_command = AsyncMock( return_value=MagicMock(exit_code=0, stdout="OK\n"), ) await _crit()._verify_sandbox_setup(ctx) - ctx.runtime.ensure_sandbox.assert_awaited_once() - ctx.runtime.run_command.assert_awaited_once() - cmd = ctx.runtime.run_command.await_args.args[0] + ctx.ensure_sandbox.assert_awaited_once() + ctx.run_command.assert_awaited_once() + cmd = ctx.run_command.await_args.args[0] assert "wc -l" in cmd assert "/tmp/smoke_health.md" in cmd @@ -43,20 +43,20 @@ async def test_verify_sandbox_setup_passes_on_ok_output() -> None: @pytest.mark.asyncio async def test_verify_sandbox_setup_raises_when_runtime_missing() -> None: ctx = MagicMock() - ctx.runtime = None - with pytest.raises(CriteriaCheckError, match="CriterionRuntime not injected"): + ctx.has_runtime = False + with pytest.raises(CriterionCheckError, match="CriterionRuntime not injected"): await _crit()._verify_sandbox_setup(ctx) @pytest.mark.asyncio async def test_verify_sandbox_setup_raises_on_non_zero_exit() -> None: ctx = MagicMock() - ctx.runtime = MagicMock() - ctx.runtime.ensure_sandbox = AsyncMock(return_value=None) - ctx.runtime.run_command = AsyncMock( + ctx.has_runtime = True + ctx.ensure_sandbox = AsyncMock(return_value=None) + ctx.run_command = AsyncMock( return_value=MagicMock(exit_code=1, stdout=""), ) - with pytest.raises(CriteriaCheckError, match=r"researchrubrics sandbox health failed.*exit=1"): + with pytest.raises(CriterionCheckError, match=r"researchrubrics sandbox health failed.*exit=1"): await _crit()._verify_sandbox_setup(ctx) @@ -65,10 +65,10 @@ async def test_verify_sandbox_setup_raises_when_ok_marker_missing() -> None: """Command exited 0 but didn't print OK — toolchain may have silently no-op'd. Treat as failure.""" ctx = MagicMock() - ctx.runtime = MagicMock() - ctx.runtime.ensure_sandbox = AsyncMock(return_value=None) - ctx.runtime.run_command = AsyncMock( + ctx.has_runtime = True + ctx.ensure_sandbox = AsyncMock(return_value=None) + ctx.run_command = AsyncMock( return_value=MagicMock(exit_code=0, stdout="nope"), ) - with pytest.raises(CriteriaCheckError, match="researchrubrics sandbox health failed"): + with pytest.raises(CriterionCheckError, match="researchrubrics sandbox health failed"): await _crit()._verify_sandbox_setup(ctx) diff --git a/tests/unit/smoke_base/test_sadpath_worker_routing.py b/tests/unit/smoke_base/test_sadpath_worker_routing.py index 15d6cae1..03803b14 100644 --- a/tests/unit/smoke_base/test_sadpath_worker_routing.py +++ b/tests/unit/smoke_base/test_sadpath_worker_routing.py @@ -8,13 +8,13 @@ import pytest from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug -from ergon_core.test_support.smoke_fixtures.workers.minif2f_smoke import ( +from tests.fixtures.smoke_components.workers.minif2f_smoke import ( MiniF2FSadPathSmokeWorker, ) -from ergon_core.test_support.smoke_fixtures.workers.researchrubrics_smoke import ( +from tests.fixtures.smoke_components.workers.researchrubrics_smoke import ( ResearchRubricsSadPathSmokeWorker, ) -from ergon_core.test_support.smoke_fixtures.workers.swebench_smoke import ( +from tests.fixtures.smoke_components.workers.swebench_smoke import ( SweBenchSadPathSmokeWorker, ) diff --git a/tests/unit/smoke_base/test_smoke_criterion_completed.py b/tests/unit/smoke_base/test_smoke_criterion_completed.py index 5f7044ff..0c58d00d 100644 --- a/tests/unit/smoke_base/test_smoke_criterion_completed.py +++ b/tests/unit/smoke_base/test_smoke_criterion_completed.py @@ -1,9 +1,9 @@ """``SmokeCriterionBase._check_children_completed`` rejects non-terminal children.""" import pytest -from ergon_core.api.errors import CriteriaCheckError +from ergon_core.api.errors import CriterionCheckError from ergon_core.core.persistence.graph.status_conventions import COMPLETED -from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from tests.fixtures.smoke_components.smoke_base.criterion_base import SmokeCriterionBase from pydantic import BaseModel @@ -25,7 +25,7 @@ async def _verify_sandbox_setup(self, context): # pragma: no cover def _crit() -> _Crit: - return _Crit(name="unit-test") + return _Crit(slug="unit-test") def test_all_completed_passes() -> None: @@ -39,7 +39,7 @@ def test_any_non_completed_raises() -> None: _FakeNode(task_slug="d_left", status=COMPLETED), _FakeNode(task_slug="d_right", status="in_progress"), ] - with pytest.raises(CriteriaCheckError, match="d_right not completed"): + with pytest.raises(CriterionCheckError, match="d_right not completed"): _crit()._check_children_completed(children) @@ -50,7 +50,7 @@ def test_failed_child_raises_with_slug() -> None: _FakeNode(task_slug="l_2", status="failed"), _FakeNode(task_slug="l_3", status="blocked"), ] - with pytest.raises(CriteriaCheckError, match="l_2 not completed"): + with pytest.raises(CriterionCheckError, match="l_2 not completed"): _crit()._check_children_completed(children) diff --git a/tests/unit/smoke_base/test_smoke_criterion_probe.py b/tests/unit/smoke_base/test_smoke_criterion_probe.py index b7fc5503..23673744 100644 --- a/tests/unit/smoke_base/test_smoke_criterion_probe.py +++ b/tests/unit/smoke_base/test_smoke_criterion_probe.py @@ -3,8 +3,8 @@ from uuid import UUID, uuid4 import pytest -from ergon_core.api.errors import CriteriaCheckError -from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import ( +from ergon_core.api.errors import CriterionCheckError +from tests.fixtures.smoke_components.smoke_base.criterion_base import ( ProbeResult, SmokeCriterionBase, ) @@ -29,7 +29,7 @@ async def _verify_sandbox_setup(self, context): # pragma: no cover def _crit() -> _Crit: - return _Crit(name="unit-test") + return _Crit(slug="unit-test") def test_all_zero_exits_passes() -> None: @@ -49,14 +49,14 @@ def test_non_zero_exit_raises_with_slug() -> None: c1.id: ProbeResult(exit_code=1, stdout="boom"), c2.id: ProbeResult(exit_code=0, stdout="ok"), } - with pytest.raises(CriteriaCheckError, match=r"l_2.*exited 1.*boom"): + with pytest.raises(CriterionCheckError, match=r"l_2.*exited 1.*boom"): _crit()._check_probes_succeeded(probes, [c1, c2]) def test_missing_exit_code_raises() -> None: c1 = _FakeNode(id=uuid4(), task_slug="d_root") probes = {c1.id: ProbeResult(stdout="no exit_code")} - with pytest.raises(CriteriaCheckError, match="d_root.*exited None"): + with pytest.raises(CriterionCheckError, match="d_root.*exited None"): _crit()._check_probes_succeeded(probes, [c1]) @@ -65,5 +65,5 @@ def test_unknown_child_id_uses_uuid_string() -> None: practice, but defensive formatting keeps the error legible).""" orphan_id = uuid4() probes = {orphan_id: ProbeResult(exit_code=2, stdout="x")} - with pytest.raises(CriteriaCheckError, match=r"exited 2"): + with pytest.raises(CriterionCheckError, match=r"exited 2"): _crit()._check_probes_succeeded(probes, []) diff --git a/tests/unit/smoke_base/test_smoke_criterion_shape.py b/tests/unit/smoke_base/test_smoke_criterion_shape.py index b3fee661..053012f7 100644 --- a/tests/unit/smoke_base/test_smoke_criterion_shape.py +++ b/tests/unit/smoke_base/test_smoke_criterion_shape.py @@ -5,9 +5,9 @@ """ import pytest -from ergon_core.api.errors import CriteriaCheckError -from ergon_core.test_support.smoke_fixtures.smoke_base.constants import EXPECTED_SUBTASK_SLUGS -from ergon_core.test_support.smoke_fixtures.smoke_base.criterion_base import SmokeCriterionBase +from ergon_core.api.errors import CriterionCheckError +from tests.fixtures.smoke_components.smoke_base.constants import EXPECTED_SUBTASK_SLUGS +from tests.fixtures.smoke_components.smoke_base.criterion_base import SmokeCriterionBase from pydantic import BaseModel @@ -28,7 +28,7 @@ async def _verify_sandbox_setup(self, context): # pragma: no cover def _crit() -> _Crit: - return _Crit(name="unit-test") + return _Crit(slug="unit-test") def test_correct_slug_set_passes() -> None: @@ -62,5 +62,5 @@ def _nodes_renamed_slug() -> list[_FakeNode]: ], ) def test_bad_slug_set_raises(children: list[_FakeNode]) -> None: - with pytest.raises(CriteriaCheckError, match="graph shape mismatch"): + with pytest.raises(CriterionCheckError, match="graph shape mismatch"): _crit()._check_graph_shape(children) diff --git a/tests/unit/smoke_base/test_smoke_sandbox_manager.py b/tests/unit/smoke_base/test_smoke_sandbox_manager.py index 709801e3..61d5045b 100644 --- a/tests/unit/smoke_base/test_smoke_sandbox_manager.py +++ b/tests/unit/smoke_base/test_smoke_sandbox_manager.py @@ -2,7 +2,7 @@ from uuid import UUID, uuid4 import pytest -from ergon_core.core.sandbox.event_sink import SandboxEventSink +from ergon_core.core.infrastructure.sandbox.event_sink import SandboxEventSink class _RecordingSink(SandboxEventSink): @@ -45,7 +45,7 @@ async def sandbox_closed( @pytest.mark.asyncio async def test_smoke_sandbox_manager_ignores_e2b_key(monkeypatch: pytest.MonkeyPatch) -> None: - from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager + from tests.fixtures.smoke_components.sandbox import SmokeSandboxManager monkeypatch.setenv("E2B_API_KEY", "present-but-smoke-uses-local-fake") run_id = uuid4() @@ -60,7 +60,7 @@ async def test_smoke_sandbox_manager_ignores_e2b_key(monkeypatch: pytest.MonkeyP @pytest.mark.asyncio async def test_smoke_sandbox_health_command_matches_swebench_probe() -> None: - from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager + from tests.fixtures.smoke_components.sandbox import SmokeSandboxManager run_id = uuid4() task_id = uuid4() @@ -82,9 +82,9 @@ async def test_smoke_sandbox_health_command_matches_swebench_probe() -> None: @pytest.mark.asyncio async def test_static_teardown_closes_registered_smoke_sandbox() -> None: - from ergon_core.core.sandbox.event_sink import NoopSandboxEventSink - from ergon_core.core.sandbox.manager import BaseSandboxManager - from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager + from ergon_core.core.infrastructure.sandbox.event_sink import NoopSandboxEventSink + from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager + from tests.fixtures.smoke_components.sandbox import SmokeSandboxManager sink = _RecordingSink() SmokeSandboxManager.set_event_sink(sink) @@ -120,40 +120,42 @@ async def test_static_teardown_closes_registered_smoke_sandbox() -> None: def test_smoke_benchmarks_use_smoke_sandbox_manager( monkeypatch: pytest.MonkeyPatch, ) -> None: - from ergon_builtins.registry import BENCHMARKS, SANDBOX_MANAGERS - from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures - from ergon_core.test_support.smoke_fixtures.benchmarks import ( + from ergon_builtins.registry import register_builtins + from ergon_core.api.registry import registry + from tests.fixtures.smoke_components import register_smoke_fixtures + from tests.fixtures.smoke_components.benchmarks import ( MiniF2FSmokeBenchmark, ResearchRubricsSmokeBenchmark, SweBenchSmokeBenchmark, ) - from ergon_core.test_support.smoke_fixtures.sandbox import SmokeSandboxManager + from tests.fixtures.smoke_components.sandbox import SmokeSandboxManager + register_builtins(registry) slugs = ( ResearchRubricsSmokeBenchmark.type_slug, MiniF2FSmokeBenchmark.type_slug, SweBenchSmokeBenchmark.type_slug, ) - original_benchmarks = {slug: BENCHMARKS[slug] for slug in slugs} - original_managers = {slug: SANDBOX_MANAGERS.get(slug) for slug in slugs} + original_benchmarks = {slug: registry.benchmarks[slug] for slug in slugs} + original_managers = {slug: registry.sandbox_managers.get(slug) for slug in slugs} monkeypatch.setenv("ENABLE_TEST_HARNESS", "1") try: register_smoke_fixtures() for slug in slugs: - assert SANDBOX_MANAGERS[slug] is SmokeSandboxManager + assert registry.sandbox_managers[slug] is SmokeSandboxManager finally: - BENCHMARKS.update(original_benchmarks) + registry.benchmarks.update(original_benchmarks) for slug, manager_cls in original_managers.items(): if manager_cls is None: - SANDBOX_MANAGERS.pop(slug, None) + registry.sandbox_managers.pop(slug, None) else: - SANDBOX_MANAGERS[slug] = manager_cls + registry.sandbox_managers[slug] = manager_cls def test_smoke_parent_treats_blocked_children_as_terminal() -> None: from ergon_core.core.persistence.graph.status_conventions import TERMINAL_STATUSES - from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import ( + from tests.fixtures.smoke_components.smoke_base.worker_base import ( _CHILD_WAIT_TERMINAL_STATUSES, ) diff --git a/tests/unit/smoke_base/test_smoke_worker_base_final.py b/tests/unit/smoke_base/test_smoke_worker_base_final.py index 14ad2369..31a5417e 100644 --- a/tests/unit/smoke_base/test_smoke_worker_base_final.py +++ b/tests/unit/smoke_base/test_smoke_worker_base_final.py @@ -11,7 +11,7 @@ """ import pytest -from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase +from tests.fixtures.smoke_components.smoke_base.worker_base import SmokeWorkerBase def test_execute_is_marked_final() -> None: diff --git a/tests/unit/smoke_base/test_smoke_worker_spec_for_override.py b/tests/unit/smoke_base/test_smoke_worker_spec_for_override.py index 09c0312c..8b9a09ee 100644 --- a/tests/unit/smoke_base/test_smoke_worker_spec_for_override.py +++ b/tests/unit/smoke_base/test_smoke_worker_spec_for_override.py @@ -13,7 +13,7 @@ from uuid import uuid4 from ergon_core.core.persistence.shared.types import AssignedWorkerSlug, TaskSlug -from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase +from tests.fixtures.smoke_components.smoke_base.worker_base import SmokeWorkerBase class _HappySubclass(SmokeWorkerBase): diff --git a/tests/unit/smoke_base/test_swebench_criterion.py b/tests/unit/smoke_base/test_swebench_criterion.py index e1bde822..4aeaaf47 100644 --- a/tests/unit/smoke_base/test_swebench_criterion.py +++ b/tests/unit/smoke_base/test_swebench_criterion.py @@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from ergon_core.api.errors import CriteriaCheckError -from ergon_core.test_support.smoke_fixtures.criteria.swebench_smoke import SweBenchSmokeCriterion +from ergon_core.api.errors import CriterionCheckError +from tests.fixtures.smoke_components.criteria.swebench_smoke import SweBenchSmokeCriterion def _crit() -> SweBenchSmokeCriterion: @@ -14,19 +14,19 @@ def _crit() -> SweBenchSmokeCriterion: @pytest.mark.asyncio async def test_sandbox_setup_passes_on_health_ok_marker() -> None: ctx = MagicMock() - ctx.runtime = MagicMock() - ctx.runtime.ensure_sandbox = AsyncMock(return_value=None) - ctx.runtime.write_file = AsyncMock(return_value=None) - ctx.runtime.run_command = AsyncMock( + ctx.has_runtime = True + ctx.ensure_sandbox = AsyncMock(return_value=None) + ctx.write_file = AsyncMock(return_value=None) + ctx.run_command = AsyncMock( return_value=MagicMock(exit_code=0, stdout="HEALTH_OK\n7.4.0\n", stderr=""), ) await _crit()._verify_sandbox_setup(ctx) - path, content = ctx.runtime.write_file.await_args.args + path, content = ctx.write_file.await_args.args assert path == "/tmp/smoke_health.py" assert b"HEALTH_OK" in content - cmd = ctx.runtime.run_command.await_args.args[0] + cmd = ctx.run_command.await_args.args[0] assert "python /tmp/smoke_health.py" in cmd assert "import pytest" in cmd @@ -35,24 +35,24 @@ async def test_sandbox_setup_passes_on_health_ok_marker() -> None: async def test_sandbox_setup_raises_when_ok_marker_missing() -> None: """Exit 0 but no HEALTH_OK → command silently no-op'd.""" ctx = MagicMock() - ctx.runtime = MagicMock() - ctx.runtime.ensure_sandbox = AsyncMock(return_value=None) - ctx.runtime.write_file = AsyncMock(return_value=None) - ctx.runtime.run_command = AsyncMock( + ctx.has_runtime = True + ctx.ensure_sandbox = AsyncMock(return_value=None) + ctx.write_file = AsyncMock(return_value=None) + ctx.run_command = AsyncMock( return_value=MagicMock(exit_code=0, stdout="something else", stderr=""), ) - with pytest.raises(CriteriaCheckError, match="swebench sandbox health failed"): + with pytest.raises(CriterionCheckError, match="swebench sandbox health failed"): await _crit()._verify_sandbox_setup(ctx) @pytest.mark.asyncio async def test_sandbox_setup_raises_on_pytest_import_error() -> None: ctx = MagicMock() - ctx.runtime = MagicMock() - ctx.runtime.ensure_sandbox = AsyncMock(return_value=None) - ctx.runtime.write_file = AsyncMock(return_value=None) - ctx.runtime.run_command = AsyncMock( + ctx.has_runtime = True + ctx.ensure_sandbox = AsyncMock(return_value=None) + ctx.write_file = AsyncMock(return_value=None) + ctx.run_command = AsyncMock( return_value=MagicMock( exit_code=1, stdout="HEALTH_OK\n", @@ -60,13 +60,13 @@ async def test_sandbox_setup_raises_on_pytest_import_error() -> None: ), ) - with pytest.raises(CriteriaCheckError, match=r"swebench sandbox health failed.*exit=1"): + with pytest.raises(CriterionCheckError, match=r"swebench sandbox health failed.*exit=1"): await _crit()._verify_sandbox_setup(ctx) @pytest.mark.asyncio async def test_sandbox_setup_raises_when_runtime_missing() -> None: ctx = MagicMock() - ctx.runtime = None - with pytest.raises(CriteriaCheckError, match="CriterionRuntime not injected"): + ctx.has_runtime = False + with pytest.raises(CriterionCheckError, match="CriterionRuntime not injected"): await _crit()._verify_sandbox_setup(ctx) From 22b7e622c84fbe4274c43e13bc3665d98a3610ce Mon Sep 17 00:00:00 2001 From: Charlie Masters <69640669+cm2435@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:46:47 +0100 Subject: [PATCH 66/66] test: update package tests and black-box harness layout Made-with: Cursor --- .github/workflows/e2e-benchmarks.yml | 4 +- Dockerfile | 2 +- docker-compose.yml | 4 +- docs/architecture/07_testing.md | 2 +- .../events/__init__.py | 6 +- .../{runtime => application}/events/base.py | 0 .../events/infrastructure_events.py | 2 +- .../events/task_events.py | 2 +- .../services/child_function_payloads.py | 81 --- .../services/inngest_function_results.py | 110 ---- .../test_support/e2e_read_helpers.py | 177 ++++++ .../services => tests/unit/api}/__init__.py | 0 .../unit/api/test_criterion_contract.py | 10 +- .../unit/api/test_public_api_imports.py | 4 +- .../unit/api/test_worker_base_contract.py | 0 .../architecture/test_api_runs_boundary.py | 4 +- .../architecture/test_core_schema_sources.py | 545 ++++++++++++++++++ .../test_model_field_descriptions.py | 6 +- .../test_no_test_logic_in_core.py | 8 +- .../test_persistence_boundaries.py | 49 ++ .../test_public_api_boundaries.py | 517 +++++++++++++++++ .../test_public_api_target_structure.py | 109 ++++ .../test_smoke_fixture_package_boundary.py | 11 +- .../dashboard/test_communication_threads.py | 2 +- .../unit/dashboard/test_emitter_provider.py | 4 +- .../dashboard/test_event_contract_types.py | 10 +- .../test_context_event_repository.py | 8 +- .../tests/unit/registry}/__init__.py | 0 .../unit/registry/test_builtin_pairings.py | 86 +++ .../unit/registry/test_component_registry.py | 66 +++ .../registry/test_core_registry_boundary.py | 17 + .../unit/registry/test_react_factories.py | 149 +++++ .../registry/test_worker_spec_validation.py | 21 + .../tests/unit/runtime}/__init__.py | 0 .../runtime/test_child_function_payloads.py | 2 +- .../test_cohort_rubric_status_summary.py | 6 +- .../unit/runtime/test_cohort_service.py | 2 +- .../runtime/test_communication_service.py | 8 +- .../runtime/test_context_event_contracts.py | 6 +- .../test_criterion_runtime_get_all_files.py | 8 +- .../test_criterion_runtime_reconnect.py | 6 +- .../test_definition_lookup_boundaries.py | 6 +- .../test_definition_task_payload_typing.py | 0 .../test_dynamic_task_evaluation_mapping.py | 2 +- .../test_evaluation_context_schemas.py | 10 +- .../test_evaluation_score_aggregation.py | 25 + .../test_evaluation_summary_contracts.py | 40 +- .../runtime/test_execute_task_readability.py | 2 +- .../test_experiment_definition_service.py | 24 +- .../runtime/test_experiment_launch_service.py | 28 +- .../runtime/test_experiment_read_service.py | 4 +- .../unit/runtime/test_experiment_schemas.py | 54 +- .../test_failed_task_sandbox_cleanup.py | 6 +- .../unit/runtime/test_failure_error_json.py | 6 +- .../runtime/test_graph_mutation_contracts.py | 4 +- .../unit/runtime/test_graph_traversal.py | 68 +++ .../runtime/test_graph_worker_identity.py | 29 +- .../unit/runtime/test_import_boundaries.py | 19 +- .../test_inngest_criterion_executor.py | 24 +- .../runtime/test_inngest_package_layout.py | 4 +- .../runtime/test_persist_outputs_resources.py | 4 +- .../runtime/test_propagation_contracts.py | 13 +- .../test_real_llm_rollout_artifact_health.py | 0 .../runtime/test_rubric_evaluation_service.py | 30 +- .../runtime/test_run_record_missing_error.py | 4 +- .../tests}/unit/runtime/test_run_service.py | 6 +- .../test_sandbox_setup_explicit_slug.py | 29 + .../unit/runtime/test_smoke_topology_drift.py | 4 +- .../runtime/test_task_execution_repository.py | 155 +++++ .../test_worker_execute_factory_call.py | 0 .../test_worker_execute_stream_contract.py | 83 +++ .../unit/runtime/test_workflow_service.py | 10 +- .../tests/unit/sandbox}/__init__.py | 0 .../test_ensure_sandbox_idempotence.py | 6 +- .../sandbox/test_sandbox_lifecycle_service.py | 4 +- .../unit/sandbox/test_sandbox_reconnect.py | 28 +- .../unit/sandbox/test_stub_sandbox_id.py | 0 .../test_app_mounts_harness_conditionally.py | 2 +- .../unit/test_dashboard_emitter_wiring.py | 8 +- .../tests}/unit/test_rollouts_di.py | 2 +- .../test_swebench_criterion_no_sandbox.py | 14 +- .../tests}/unit/test_test_harness.py | 8 +- package.json | 4 +- scripts/export_contract_schemas.py | 4 +- scripts/smoke_local_up.sh | 2 +- scripts/smoke_reassert.py | 2 +- tests/conftest.py | 2 +- tests/e2e/_asserts.py | 151 ++--- tests/e2e/_read_contracts.py | 4 +- tests/e2e/_submit.py | 45 +- tests/e2e/conftest.py | 29 +- tests/e2e/test_minif2f_smoke.py | 2 + tests/e2e/test_researchrubrics_smoke.py | 2 + tests/e2e/test_swebench_smoke.py | 2 + tests/integration/conftest.py | 4 +- .../minif2f/test_sandbox_manager.py | 14 +- .../minif2f/test_verification_integration.py | 18 +- .../propagation/test_add_subtask_dispatch.py | 10 +- .../propagation/test_propagation_blocked.py | 16 +- .../propagation/test_propagation_cancel.py | 4 +- .../test_propagation_edge_cases.py | 12 +- .../propagation/test_propagation_happy.py | 12 +- .../propagation/test_propagation_restart.py | 10 +- .../researchrubrics/test_sandbox_manager.py | 14 +- .../restart/test_downstream_invalidation.py | 8 +- .../restart/test_manager_dag_scenario.py | 16 +- .../integration/restart/test_reactivation.py | 10 +- .../integration/restart/test_restart_task.py | 10 +- .../sandbox/test_required_env_keys.py | 8 +- .../integration/smokes/test_smoke_harness.py | 34 ++ .../swebench_verified/test_criterion.py | 16 +- .../swebench_verified/test_sandbox_manager.py | 10 +- .../benchmarks/test_researchrubrics.py | 2 +- tests/real_llm/benchmarks/test_smoke_stub.py | 2 +- tests/real_llm/fixtures/openrouter_budget.py | 2 +- tests/real_llm/openrouter_budget.py | 2 +- .../architecture/test_core_schema_sources.py | 125 ---- .../architecture/test_package_test_layout.py | 21 + .../test_persistence_boundaries.py | 49 -- .../test_public_api_boundaries.py | 52 -- .../test_minif2f_proof_verification.py | 16 +- .../test_swebench_criterion_patch_source.py | 14 +- .../test_swebench_sandbox_manager.py | 32 +- .../common/test_transcript_adapters.py | 2 +- tests/unit/cli/test_benchmark_setup.py | 24 +- tests/unit/cli/test_experiment_cli.py | 146 ++++- tests/unit/cli/test_workflow_cli.py | 4 +- tests/unit/providers/test_model_resolution.py | 45 -- tests/unit/registry/test_react_factories.py | 64 -- tests/unit/sandbox/__init__.py | 0 tests/unit/state/test_benchmark_contract.py | 22 +- tests/unit/state/test_context_assembly.py | 2 +- tests/unit/state/test_context_part_stream.py | 2 +- tests/unit/state/test_criterion_runtime_di.py | 28 +- tests/unit/state/test_event_schema_phase0.py | 6 +- .../state/test_llm_judge_runtime_injection.py | 21 +- .../state/test_research_rubrics_benchmark.py | 22 +- .../state/test_research_rubrics_workers.py | 11 +- tests/unit/state/test_workflow_cli_tool.py | 2 +- .../workers/test_react_worker_contract.py | 32 +- 140 files changed, 2937 insertions(+), 1107 deletions(-) rename ergon_core/ergon_core/core/{runtime => application}/events/__init__.py (67%) rename ergon_core/ergon_core/core/{runtime => application}/events/base.py (100%) rename ergon_core/ergon_core/core/{runtime => application}/events/infrastructure_events.py (87%) rename ergon_core/ergon_core/core/{runtime => application}/events/task_events.py (97%) delete mode 100644 ergon_core/ergon_core/core/runtime/services/child_function_payloads.py delete mode 100644 ergon_core/ergon_core/core/runtime/services/inngest_function_results.py create mode 100644 ergon_core/ergon_core/test_support/e2e_read_helpers.py rename ergon_core/{ergon_core/core/runtime/services => tests/unit/api}/__init__.py (100%) rename {tests => ergon_core/tests}/unit/api/test_criterion_contract.py (72%) rename {tests => ergon_core/tests}/unit/api/test_public_api_imports.py (91%) rename {tests => ergon_core/tests}/unit/api/test_worker_base_contract.py (100%) rename {tests => ergon_core/tests}/unit/architecture/test_api_runs_boundary.py (95%) create mode 100644 ergon_core/tests/unit/architecture/test_core_schema_sources.py rename {tests => ergon_core/tests}/unit/architecture/test_model_field_descriptions.py (93%) rename {tests => ergon_core/tests}/unit/architecture/test_no_test_logic_in_core.py (88%) create mode 100644 ergon_core/tests/unit/architecture/test_persistence_boundaries.py create mode 100644 ergon_core/tests/unit/architecture/test_public_api_boundaries.py create mode 100644 ergon_core/tests/unit/architecture/test_public_api_target_structure.py rename {tests => ergon_core/tests}/unit/architecture/test_smoke_fixture_package_boundary.py (58%) rename {tests => ergon_core/tests}/unit/dashboard/test_communication_threads.py (96%) rename {tests => ergon_core/tests}/unit/dashboard/test_emitter_provider.py (88%) rename {tests => ergon_core/tests}/unit/dashboard/test_event_contract_types.py (82%) rename {tests => ergon_core/tests}/unit/persistence/test_context_event_repository.py (96%) rename {tests/unit/api => ergon_core/tests/unit/registry}/__init__.py (100%) create mode 100644 ergon_core/tests/unit/registry/test_builtin_pairings.py create mode 100644 ergon_core/tests/unit/registry/test_component_registry.py create mode 100644 ergon_core/tests/unit/registry/test_core_registry_boundary.py create mode 100644 ergon_core/tests/unit/registry/test_react_factories.py create mode 100644 ergon_core/tests/unit/registry/test_worker_spec_validation.py rename {tests/unit/registry => ergon_core/tests/unit/runtime}/__init__.py (100%) rename {tests => ergon_core/tests}/unit/runtime/test_child_function_payloads.py (96%) rename {tests => ergon_core/tests}/unit/runtime/test_cohort_rubric_status_summary.py (94%) rename {tests => ergon_core/tests}/unit/runtime/test_cohort_service.py (96%) rename {tests => ergon_core/tests}/unit/runtime/test_communication_service.py (91%) rename {tests => ergon_core/tests}/unit/runtime/test_context_event_contracts.py (76%) rename {tests => ergon_core/tests}/unit/runtime/test_criterion_runtime_get_all_files.py (92%) rename {tests => ergon_core/tests}/unit/runtime/test_criterion_runtime_reconnect.py (95%) rename {tests => ergon_core/tests}/unit/runtime/test_definition_lookup_boundaries.py (60%) rename {tests => ergon_core/tests}/unit/runtime/test_definition_task_payload_typing.py (100%) rename {tests => ergon_core/tests}/unit/runtime/test_dynamic_task_evaluation_mapping.py (95%) rename {tests => ergon_core/tests}/unit/runtime/test_evaluation_context_schemas.py (90%) create mode 100644 ergon_core/tests/unit/runtime/test_evaluation_score_aggregation.py rename {tests => ergon_core/tests}/unit/runtime/test_evaluation_summary_contracts.py (90%) rename {tests => ergon_core/tests}/unit/runtime/test_execute_task_readability.py (87%) rename {tests => ergon_core/tests}/unit/runtime/test_experiment_definition_service.py (75%) rename {tests => ergon_core/tests}/unit/runtime/test_experiment_launch_service.py (72%) rename {tests => ergon_core/tests}/unit/runtime/test_experiment_read_service.py (96%) rename {tests => ergon_core/tests}/unit/runtime/test_experiment_schemas.py (52%) rename {tests => ergon_core/tests}/unit/runtime/test_failed_task_sandbox_cleanup.py (70%) rename {tests => ergon_core/tests}/unit/runtime/test_failure_error_json.py (86%) rename {tests => ergon_core/tests}/unit/runtime/test_graph_mutation_contracts.py (87%) create mode 100644 ergon_core/tests/unit/runtime/test_graph_traversal.py rename {tests => ergon_core/tests}/unit/runtime/test_graph_worker_identity.py (89%) rename {tests => ergon_core/tests}/unit/runtime/test_import_boundaries.py (54%) rename {tests => ergon_core/tests}/unit/runtime/test_inngest_criterion_executor.py (69%) rename {tests => ergon_core/tests}/unit/runtime/test_inngest_package_layout.py (51%) rename {tests => ergon_core/tests}/unit/runtime/test_persist_outputs_resources.py (87%) rename {tests => ergon_core/tests}/unit/runtime/test_propagation_contracts.py (69%) rename {tests => ergon_core/tests}/unit/runtime/test_real_llm_rollout_artifact_health.py (100%) rename {tests => ergon_core/tests}/unit/runtime/test_rubric_evaluation_service.py (68%) rename {tests => ergon_core/tests}/unit/runtime/test_run_record_missing_error.py (85%) rename {tests => ergon_core/tests}/unit/runtime/test_run_service.py (91%) create mode 100644 ergon_core/tests/unit/runtime/test_sandbox_setup_explicit_slug.py rename {tests => ergon_core/tests}/unit/runtime/test_smoke_topology_drift.py (88%) create mode 100644 ergon_core/tests/unit/runtime/test_task_execution_repository.py rename {tests => ergon_core/tests}/unit/runtime/test_worker_execute_factory_call.py (100%) create mode 100644 ergon_core/tests/unit/runtime/test_worker_execute_stream_contract.py rename {tests => ergon_core/tests}/unit/runtime/test_workflow_service.py (98%) rename {tests/unit/runtime => ergon_core/tests/unit/sandbox}/__init__.py (100%) rename {tests => ergon_core/tests}/unit/sandbox/test_ensure_sandbox_idempotence.py (94%) rename {tests => ergon_core/tests}/unit/sandbox/test_sandbox_lifecycle_service.py (83%) rename {tests => ergon_core/tests}/unit/sandbox/test_sandbox_reconnect.py (86%) rename {tests => ergon_core/tests}/unit/sandbox/test_stub_sandbox_id.py (100%) rename {tests => ergon_core/tests}/unit/test_app_mounts_harness_conditionally.py (97%) rename {tests => ergon_core/tests}/unit/test_dashboard_emitter_wiring.py (95%) rename {tests => ergon_core/tests}/unit/test_rollouts_di.py (96%) rename {tests => ergon_core/tests}/unit/test_swebench_criterion_no_sandbox.py (91%) rename {tests => ergon_core/tests}/unit/test_test_harness.py (93%) delete mode 100644 tests/unit/architecture/test_core_schema_sources.py create mode 100644 tests/unit/architecture/test_package_test_layout.py delete mode 100644 tests/unit/architecture/test_persistence_boundaries.py delete mode 100644 tests/unit/architecture/test_public_api_boundaries.py delete mode 100644 tests/unit/providers/test_model_resolution.py delete mode 100644 tests/unit/registry/test_react_factories.py delete mode 100644 tests/unit/sandbox/__init__.py diff --git a/.github/workflows/e2e-benchmarks.yml b/.github/workflows/e2e-benchmarks.yml index 20aa7350..abf33190 100644 --- a/.github/workflows/e2e-benchmarks.yml +++ b/.github/workflows/e2e-benchmarks.yml @@ -35,7 +35,7 @@ jobs: env: SMOKE_ENV: ${{ matrix.env }} ENABLE_TEST_HARNESS: "1" - ERGON_STARTUP_PLUGINS: "ergon_core.test_support.smoke_fixtures:register_smoke_fixtures" + ERGON_STARTUP_PLUGINS: "ergon_builtins.registry:register_builtins,tests.fixtures.smoke_components:register_smoke_fixtures" TEST_HARNESS_SECRET: ${{ secrets.TEST_HARNESS_SECRET || 'ci-test-harness' }} E2B_API_KEY: ${{ secrets.E2B_API_KEY }} GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} @@ -74,7 +74,7 @@ jobs: # Unified compose reads these as overrides (see docker-compose.yml). POSTGRES_PASSWORD: ci_test ENABLE_TEST_HARNESS: "1" - ERGON_STARTUP_PLUGINS: "ergon_core.test_support.smoke_fixtures:register_smoke_fixtures" + ERGON_STARTUP_PLUGINS: "ergon_builtins.registry:register_builtins,tests.fixtures.smoke_components:register_smoke_fixtures" run: docker compose up -d --build --wait timeout-minutes: 5 diff --git a/Dockerfile b/Dockerfile index 2b481776..e9fb0b61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,4 +37,4 @@ RUN cd ergon_cli && uv pip install --system -e "." EXPOSE 9000 -CMD ["uvicorn", "ergon_core.core.api.app:app", "--host", "0.0.0.0", "--port", "9000"] +CMD ["uvicorn", "ergon_core.core.rest_api.app:app", "--host", "0.0.0.0", "--port", "9000"] diff --git a/docker-compose.yml b/docker-compose.yml index 2adb82bf..6fdbdb7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,7 @@ services: - INNGEST_API_BASE_URL=http://inngest-dev:8288 - ERGON_API_BASE_URL=http://api:9000 - ENABLE_TEST_HARNESS=${ENABLE_TEST_HARNESS:-1} - - ERGON_STARTUP_PLUGINS=${ERGON_STARTUP_PLUGINS-ergon_core.test_support.smoke_fixtures:register_smoke_fixtures} + - ERGON_STARTUP_PLUGINS=${ERGON_STARTUP_PLUGINS-ergon_builtins.registry:register_builtins,tests.fixtures.smoke_components:register_smoke_fixtures} - TEST_HARNESS_SECRET=${TEST_HARNESS_SECRET:-local-dev} - ERGON_BLOB_ROOT=/tmp/ergon-blob - OTEL_TRACES_ENABLED=false @@ -120,7 +120,7 @@ services: postgres: condition: service_healthy command: > - uvicorn ergon_core.core.api.app:app + uvicorn ergon_core.core.rest_api.app:app --host 0.0.0.0 --port 9000 --reload --reload-dir /app/ergon_core --reload-dir /app/ergon_builtins diff --git a/docs/architecture/07_testing.md b/docs/architecture/07_testing.md index f383dd65..69ceb37e 100644 --- a/docs/architecture/07_testing.md +++ b/docs/architecture/07_testing.md @@ -69,7 +69,7 @@ EXPECTED_SUBTASK_SLUGS = ( ### 3.2 Fixture residency — test-only, out of `ergon_builtins` -`ergon_builtins/` contains only production baselines (ReActWorker, TrainingStubWorker). All smoke workers, leaves, and criteria live under [`ergon_core/test_support/smoke_fixtures/`](../../ergon_core/ergon_core/test_support/smoke_fixtures/) and register into the process-level `WORKERS` / `EVALUATORS` dicts through `register_smoke_fixtures()`. +`ergon_builtins/` contains only production baselines (ReActWorker, TrainingStubWorker). All smoke workers, leaves, and criteria live under [`tests/fixtures/smoke_components/`](../../tests/fixtures/smoke_components/) and register into the process-level core component registry through `register_smoke_fixtures()`. 19 registry rows total — none production: diff --git a/ergon_core/ergon_core/core/runtime/events/__init__.py b/ergon_core/ergon_core/core/application/events/__init__.py similarity index 67% rename from ergon_core/ergon_core/core/runtime/events/__init__.py rename to ergon_core/ergon_core/core/application/events/__init__.py index a03f0793..b8a04a6e 100644 --- a/ergon_core/ergon_core/core/runtime/events/__init__.py +++ b/ergon_core/ergon_core/core/application/events/__init__.py @@ -1,8 +1,8 @@ """Inngest event contracts.""" -from ergon_core.core.runtime.events.base import InngestEventContract -from ergon_core.core.runtime.events.infrastructure_events import RunCleanupEvent -from ergon_core.core.runtime.events.task_events import ( +from ergon_core.core.application.events.base import InngestEventContract +from ergon_core.core.application.events.infrastructure_events import RunCleanupEvent +from ergon_core.core.application.events.task_events import ( TaskCompletedEvent, TaskFailedEvent, TaskReadyEvent, diff --git a/ergon_core/ergon_core/core/runtime/events/base.py b/ergon_core/ergon_core/core/application/events/base.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/events/base.py rename to ergon_core/ergon_core/core/application/events/base.py diff --git a/ergon_core/ergon_core/core/runtime/events/infrastructure_events.py b/ergon_core/ergon_core/core/application/events/infrastructure_events.py similarity index 87% rename from ergon_core/ergon_core/core/runtime/events/infrastructure_events.py rename to ergon_core/ergon_core/core/application/events/infrastructure_events.py index d038cee6..bfd3b09b 100644 --- a/ergon_core/ergon_core/core/runtime/events/infrastructure_events.py +++ b/ergon_core/ergon_core/core/application/events/infrastructure_events.py @@ -3,7 +3,7 @@ from typing import ClassVar from uuid import UUID -from ergon_core.core.runtime.events.base import InngestEventContract +from ergon_core.core.application.events.base import InngestEventContract class RunCancelledEvent(InngestEventContract): diff --git a/ergon_core/ergon_core/core/runtime/events/task_events.py b/ergon_core/ergon_core/core/application/events/task_events.py similarity index 97% rename from ergon_core/ergon_core/core/runtime/events/task_events.py rename to ergon_core/ergon_core/core/application/events/task_events.py index 42719db5..ae74e530 100644 --- a/ergon_core/ergon_core/core/runtime/events/task_events.py +++ b/ergon_core/ergon_core/core/application/events/task_events.py @@ -6,7 +6,7 @@ from typing import ClassVar, Literal from uuid import UUID -from ergon_core.core.runtime.events.base import InngestEventContract +from ergon_core.core.application.events.base import InngestEventContract # Production task execution emits real sandbox IDs. Test-support managers may # use sentinel IDs, but core event consumers must not parse or branch on those diff --git a/ergon_core/ergon_core/core/runtime/services/child_function_payloads.py b/ergon_core/ergon_core/core/runtime/services/child_function_payloads.py deleted file mode 100644 index 6a8fd798..00000000 --- a/ergon_core/ergon_core/core/runtime/services/child_function_payloads.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Typed request payloads for Inngest child function invocations. - -These are passed via ctx.step.invoke(data=...) from task_execute -to its child functions. They must allow extra fields because Inngest -injects `_inngest` metadata into event data. -""" - -from typing import ClassVar -from uuid import UUID - -from ergon_core.core.runtime.events.base import InngestEventContract -from pydantic import Field, model_validator - - -class SandboxSetupRequest(InngestEventContract): - model_config = {"extra": "allow"} - name: ClassVar[str] = "task/sandbox-setup" - - run_id: UUID - definition_id: UUID - # For static tasks this is the definition task id; for dynamic subtasks it - # is the graph node id used as the sandbox registry key. - task_id: UUID - benchmark_type: str - input_resource_ids: list[UUID] = Field(default_factory=list) - envs: dict[str, str] = Field(default_factory=dict) - - -class WorkerExecuteRequest(InngestEventContract): - model_config = {"extra": "allow"} - name: ClassVar[str] = "task/worker-execute" - - run_id: UUID - definition_id: UUID - task_id: UUID | None - execution_id: UUID - sandbox_id: str - task_slug: str - task_description: str - assigned_worker_slug: str - worker_type: str - model_target: str - benchmark_type: str - node_id: UUID | None = None - - @model_validator(mode="after") - def _has_static_or_dynamic_identity(self) -> "WorkerExecuteRequest": - if self.task_id is None and self.node_id is None: - raise ValueError("WorkerExecuteRequest requires task_id or node_id") - return self - - -class PersistOutputsRequest(InngestEventContract): - model_config = {"extra": "allow"} - name: ClassVar[str] = "task/persist-outputs" - - run_id: UUID - definition_id: UUID - # Matches SandboxSetupRequest.task_id: definition task id for static tasks, - # graph node id for dynamic subtasks. - task_id: UUID - execution_id: UUID - sandbox_id: str | None = None - output_dir: str | None = None - benchmark_type: str - - -class EvaluateTaskRunRequest(InngestEventContract): - model_config = {"extra": "allow"} - name: ClassVar[str] = "task/evaluate" - - run_id: UUID - definition_id: UUID - task_id: UUID | None = None - node_id: UUID - execution_id: UUID - evaluator_id: UUID - evaluator_binding_key: str - evaluator_type: str - agent_reasoning: str | None = None - sandbox_id: str | None = None diff --git a/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py b/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py deleted file mode 100644 index 251a7b76..00000000 --- a/ergon_core/ergon_core/core/runtime/services/inngest_function_results.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Typed result objects returned by Inngest functions. - -Each Inngest function has an output_type for structured returns. -""" - -from typing import Literal -from uuid import UUID - -from ergon_core.core.json_types import JsonObject -from pydantic import BaseModel, Field - - -class WorkflowStartResult(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - initial_ready_tasks: int = 0 - total_tasks: int = 0 - - -class TaskExecuteResult(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - task_id: UUID | None - execution_id: UUID - success: bool = False - skipped: bool = False - skip_reason: str | None = None - outputs_count: int = 0 - error: str | None = None - - -class TaskPropagateResult(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - task_id: UUID | None - newly_ready_tasks: int = 0 - workflow_complete: bool = False - workflow_failed: bool = False - - -class WorkflowCompleteResult(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - status: Literal["completed"] = "completed" - final_score: float | None = None - normalized_score: float | None = None - evaluators_count: int = 0 - - -class WorkflowFailedResult(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - status: Literal["failed"] = "failed" - error: str | None = None - - -class SandboxReadyResult(BaseModel): - model_config = {"frozen": True} - - sandbox_id: str - output_dir: str | None = None - - -class WorkerExecuteResult(BaseModel): - model_config = {"frozen": True} - - success: bool = False - final_assistant_message: str | None = None - error: str | None = None - error_json: JsonObject | None = None - - -class PersistOutputsResult(BaseModel): - model_config = {"frozen": True} - - output_resource_ids: list[UUID] = Field(default_factory=list) - outputs_count: int = 0 - - -class EvaluatorsResult(BaseModel): - model_config = {"frozen": True} - - task_id: UUID | None - evaluators_found: int = 0 - evaluators_run: int = 0 - scores: list[float | None] = Field(default_factory=list) - - -class EvaluateTaskRunResult(BaseModel): - model_config = {"frozen": True} - - score: float | None = None - passed: bool | None = None - evaluator_name: str = "" # slopcop: ignore[no-str-empty-default] - error: str | None = None - - -class RunCleanupResult(BaseModel): - model_config = {"frozen": True} - - run_id: UUID - status: str | None = None - sandbox_terminated: bool = False - sandbox_id: str | None = None - error: str | None = None diff --git a/ergon_core/ergon_core/test_support/e2e_read_helpers.py b/ergon_core/ergon_core/test_support/e2e_read_helpers.py new file mode 100644 index 00000000..9800b351 --- /dev/null +++ b/ergon_core/ergon_core/test_support/e2e_read_helpers.py @@ -0,0 +1,177 @@ +"""Stable test-support reads for e2e smoke assertions.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from uuid import UUID + +from ergon_core.core.persistence.graph.models import RunGraphNode +from ergon_core.core.persistence.shared.db import get_session +from ergon_core.core.persistence.telemetry.models import ( + RunResource, + RunTaskEvaluation, + RunTaskExecution, + SandboxCommandWalEntry, + SandboxEvent, +) +from sqlmodel import select + + +@dataclass(frozen=True) +class ResourceSnapshot: + name: str + file_path: str + content_hash: str | None + kind: str + created_at: datetime + + +@dataclass(frozen=True) +class TaskExecutionSnapshot: + node_id: UUID | None + started_at: datetime | None + completed_at: datetime | None + + +@dataclass(frozen=True) +class TaskEvaluationSnapshot: + score: float + created_at: datetime + + +@dataclass(frozen=True) +class SandboxCommandWalSnapshot: + command: str + + +@dataclass(frozen=True) +class SandboxEventSnapshot: + sandbox_id: str + kind: str + + +def _resource_snapshot(row: RunResource) -> ResourceSnapshot: + return ResourceSnapshot( + name=row.name, + file_path=row.file_path, + content_hash=row.content_hash, + kind=row.kind, + created_at=row.created_at, + ) + + +def _execution_snapshot(row: RunTaskExecution) -> TaskExecutionSnapshot: + return TaskExecutionSnapshot( + node_id=row.node_id, + started_at=row.started_at, + completed_at=row.completed_at, + ) + + +def _evaluation_snapshot(row: RunTaskEvaluation) -> TaskEvaluationSnapshot: + return TaskEvaluationSnapshot(score=row.score, created_at=row.created_at) + + +def read_resource_bytes(resource: ResourceSnapshot) -> bytes: + return Path(resource.file_path).read_bytes() + + +def first_probe_resource(run_id: UUID) -> ResourceSnapshot | None: + with get_session() as session: + row = session.exec( + select(RunResource) + .where(RunResource.run_id == run_id) + .where( + RunResource.name.like("probe_%.json"), # ty: ignore[unresolved-attribute] + ) + .where(RunResource.kind == "report") + .order_by( + RunResource.created_at, # ty: ignore[unresolved-attribute] + ) + .limit(1), + ).first() + return None if row is None else _resource_snapshot(row) + + +def list_named_resources( + run_id: UUID, + *, + prefix: str, + suffix: str, +) -> list[ResourceSnapshot]: + with get_session() as session: + rows = list( + session.exec( + select(RunResource) + .where(RunResource.run_id == run_id) + .where( + RunResource.name.like(f"{prefix}%{suffix}"), # ty: ignore[unresolved-attribute] + ), + ).all(), + ) + return [_resource_snapshot(row) for row in rows] + + +def list_root_execution_and_evaluations( + run_id: UUID, +) -> tuple[TaskExecutionSnapshot | None, list[TaskEvaluationSnapshot]]: + with get_session() as session: + root = session.exec( + select(RunGraphNode) + .where(RunGraphNode.run_id == run_id) + .where(RunGraphNode.level == 0), + ).one() + execution = session.exec( + select(RunTaskExecution).where(RunTaskExecution.node_id == root.id), + ).first() + evaluations = list( + session.exec( + select(RunTaskEvaluation) + .where(RunTaskEvaluation.run_id == run_id) + .where(RunTaskEvaluation.node_id == root.id), + ).all(), + ) + execution_snapshot = None if execution is None else _execution_snapshot(execution) + return execution_snapshot, [_evaluation_snapshot(row) for row in evaluations] + + +def list_sandbox_command_wal(run_id: UUID) -> list[SandboxCommandWalSnapshot]: + with get_session() as session: + rows = list( + session.exec( + select(SandboxCommandWalEntry).where(SandboxCommandWalEntry.run_id == run_id), + ).all(), + ) + return [SandboxCommandWalSnapshot(command=row.command) for row in rows] + + +def list_sandbox_events(run_id: UUID) -> list[SandboxEventSnapshot]: + with get_session() as session: + rows = list(session.exec(select(SandboxEvent).where(SandboxEvent.run_id == run_id)).all()) + return [SandboxEventSnapshot(sandbox_id=row.sandbox_id, kind=row.kind) for row in rows] + + +def leaf_execution_timings_by_slug(run_id: UUID) -> dict[str, TaskExecutionSnapshot | None]: + with get_session() as session: + leaves = list( + session.exec( + select(RunGraphNode) + .where(RunGraphNode.run_id == run_id) + .where(RunGraphNode.level > 0), + ).all(), + ) + executions = list( + session.exec( + select(RunTaskExecution) + .where(RunTaskExecution.run_id == run_id) + .where( + RunTaskExecution.node_id.in_([leaf.id for leaf in leaves]), # ty: ignore[unresolved-attribute] + ), + ).all(), + ) + + by_node = {execution.node_id: _execution_snapshot(execution) for execution in executions} + return {leaf.task_slug: by_node.get(leaf.id) for leaf in leaves} + diff --git a/ergon_core/ergon_core/core/runtime/services/__init__.py b/ergon_core/tests/unit/api/__init__.py similarity index 100% rename from ergon_core/ergon_core/core/runtime/services/__init__.py rename to ergon_core/tests/unit/api/__init__.py diff --git a/tests/unit/api/test_criterion_contract.py b/ergon_core/tests/unit/api/test_criterion_contract.py similarity index 72% rename from tests/unit/api/test_criterion_contract.py rename to ergon_core/tests/unit/api/test_criterion_contract.py index ef53e610..96423ea0 100644 --- a/tests/unit/api/test_criterion_contract.py +++ b/ergon_core/tests/unit/api/test_criterion_contract.py @@ -3,15 +3,15 @@ import pytest from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult, CriterionScoreSpec +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.criterion import CriterionOutcome, ScoreScale class _Criterion(Criterion): type_slug = "test-criterion" - async def evaluate(self, context: EvaluationContext) -> CriterionResult: - return CriterionResult( + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: + return CriterionOutcome( name=self.slug, score=self.score_spec.max_score, passed=True, @@ -26,7 +26,7 @@ def test_criterion_requires_slug_keyword() -> None: def test_criterion_exposes_slug_and_score_spec_without_compatibility_aliases() -> None: criterion = _Criterion( slug="canonical-slug", - score_spec=CriterionScoreSpec(max_score=2.5), + score_spec=ScoreScale(max_score=2.5), ) assert criterion.slug == "canonical-slug" diff --git a/tests/unit/api/test_public_api_imports.py b/ergon_core/tests/unit/api/test_public_api_imports.py similarity index 91% rename from tests/unit/api/test_public_api_imports.py rename to ergon_core/tests/unit/api/test_public_api_imports.py index d4bca237..1c33ddc6 100644 --- a/tests/unit/api/test_public_api_imports.py +++ b/ergon_core/tests/unit/api/test_public_api_imports.py @@ -28,12 +28,12 @@ def test_object_first_experiment_run_api_is_retired() -> None: public_api = importlib.import_module("ergon_core.api") assert not hasattr(public_api, "ExperimentRunHandle") - assert not hasattr(public_api.Experiment, "run") + assert not hasattr(public_api, "Experiment") def test_core_api_app_imports_without_context_payload_cycle() -> None: proc = subprocess.run( - [sys.executable, "-c", "import ergon_core.core.api.app; print('ok')"], + [sys.executable, "-c", "import ergon_core.core.rest_api.app; print('ok')"], capture_output=True, text=True, check=False, diff --git a/tests/unit/api/test_worker_base_contract.py b/ergon_core/tests/unit/api/test_worker_base_contract.py similarity index 100% rename from tests/unit/api/test_worker_base_contract.py rename to ergon_core/tests/unit/api/test_worker_base_contract.py diff --git a/tests/unit/architecture/test_api_runs_boundary.py b/ergon_core/tests/unit/architecture/test_api_runs_boundary.py similarity index 95% rename from tests/unit/architecture/test_api_runs_boundary.py rename to ergon_core/tests/unit/architecture/test_api_runs_boundary.py index 5c385140..d0e52824 100644 --- a/tests/unit/architecture/test_api_runs_boundary.py +++ b/ergon_core/tests/unit/architecture/test_api_runs_boundary.py @@ -3,11 +3,11 @@ RUNS_API_PATH = ( - Path(__file__).resolve().parents[3] + Path(__file__).resolve().parents[4] / "ergon_core" / "ergon_core" / "core" - / "api" + / "rest_api" / "runs.py" ) diff --git a/ergon_core/tests/unit/architecture/test_core_schema_sources.py b/ergon_core/tests/unit/architecture/test_core_schema_sources.py new file mode 100644 index 00000000..dfcded16 --- /dev/null +++ b/ergon_core/tests/unit/architecture/test_core_schema_sources.py @@ -0,0 +1,545 @@ +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] + +CONFIG_REFERENCE_FILES = ( + ROOT / "pyproject.toml", + ROOT / "Dockerfile", + ROOT / "docker-compose.yml", +) + + +def test_graph_status_literals_are_defined_only_in_status_conventions() -> None: + offenders: list[str] = [] + duplicate_snippets = ( + 'Literal["pending", "ready", "running", "completed", "failed", "cancelled", "blocked"]', + 'Literal["pending", "ready", "running", "completed", "failed", "blocked", "cancelled"]', + 'Literal["pending", "satisfied", "invalidated"]', + ) + allowed = { + ROOT / "ergon_core/ergon_core/core/persistence/graph/status_conventions.py", + } + + for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): + if path in allowed: + continue + text = path.read_text() + compact_text = "".join(text.split()).replace(",]", "]") + for snippet in duplicate_snippets: + if snippet in text or "".join(snippet.split()) in compact_text: + offenders.append(f"{path.relative_to(ROOT)} duplicates {snippet}") + + assert offenders == [] + + +def test_eval_criterion_status_literal_is_defined_only_in_evaluation_summary() -> None: + offenders: list[str] = [] + snippet = 'EvalCriterionStatus=Literal["passed","failed","errored","skipped"]' + allowed = { + ROOT / "ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py", + } + + for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): + if path in allowed: + continue + compact_text = "".join(path.read_text().split()).replace(",]", "]") + if snippet in compact_text: + offenders.append(str(path.relative_to(ROOT))) + + assert offenders == [] + + +def test_run_task_dto_does_not_label_worker_slug_as_name() -> None: + path = ROOT / "ergon_core/ergon_core/core/application/read_models/models.py" + text = path.read_text() + assert "assigned_worker_name" not in text + assert "assigned_worker_slug" in text + + +def test_workflow_task_ref_does_not_duplicate_graph_task_ref() -> None: + path = ROOT / "ergon_core/ergon_core/core/application/workflows/models.py" + assert "class WorkflowTaskRef" not in path.read_text() + + +def test_cancel_cause_literals_live_in_task_events() -> None: + offenders: list[str] = [] + snippets = ( + 'Literal["parent_terminal", "dep_invalidated"]', + 'Literal["dep_invalidated", "parent_terminal"]', + ) + allowed = { + ROOT / "ergon_core/ergon_core/core/application/events/task_events.py", + } + + for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): + if path in allowed: + continue + text = path.read_text() + compact_text = "".join(text.split()).replace(",]", "]") + for snippet in snippets: + if snippet in text or "".join(snippet.split()) in compact_text: + offenders.append(f"{path.relative_to(ROOT)} duplicates cancel cause subset") + + assert offenders == [] + + +def test_core_schema_source_imports_are_directional() -> None: + forbidden_pairs = { + "ergon_core.core.application.read_models.models": ( + "EvalCriterionStatus = Literal", + "GraphMutationValue =", + ), + "ergon_core.core.infrastructure.dashboard.event_contracts": ( + "GraphMutationValue =", + "CancelCause = Literal", + ), + } + + offenders: list[str] = [] + for module_path, snippets in forbidden_pairs.items(): + path = ROOT / ("ergon_core/" + module_path.replace(".", "/") + ".py") + text = path.read_text() + for snippet in snippets: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} contains local source {snippet!r}") + + assert offenders == [] + + +def test_core_uses_hybrid_domain_layout_roots() -> None: + core = ROOT / "ergon_core/ergon_core/core" + + expected_dirs = { + "application", + "domain", + "infrastructure", + "persistence", + "rest_api", + "rl", + "shared", + } + removed_dirs = { + "runtime", + "api", + "definitions", + "composition", + "sandbox", + "dashboard", + } + actual_dirs = { + path.name for path in core.iterdir() if path.is_dir() and path.name != "__pycache__" + } + + assert expected_dirs <= actual_dirs + assert actual_dirs.isdisjoint(removed_dirs) + + +def test_core_hybrid_layout_import_directions() -> None: + forbidden_imports = { + "domain": ( + "ergon_core.core.application", + "ergon_core.core.persistence", + "ergon_core.core.infrastructure", + "ergon_core.core.rest_api", + ), + "persistence": ( + "ergon_core.core.application", + "ergon_core.core.infrastructure", + "ergon_core.core.rest_api", + ), + "application": ( + "ergon_core.core.rest_api", + "ergon_core.core.infrastructure.inngest.handlers", + ), + } + + offenders: list[str] = [] + for root_name, snippets in forbidden_imports.items(): + root = ROOT / "ergon_core/ergon_core/core" / root_name + for path in root.rglob("*.py"): + text = path.read_text() + for snippet in snippets: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} imports {snippet}") + + assert offenders == [] + + +def test_application_event_contracts_do_not_import_outer_layers() -> None: + events_root = ROOT / "ergon_core/ergon_core/core/application/events" + forbidden_imports = ( + "ergon_core.core.infrastructure", + "ergon_core.core.persistence", + "ergon_core.core.rest_api", + ) + + offenders: list[str] = [] + for path in events_root.rglob("*.py"): + text = path.read_text() + for snippet in forbidden_imports: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} imports {snippet}") + + assert offenders == [] + + +def test_runtime_event_contract_references_do_not_return() -> None: + checked_paths = [ + path + for base in ( + ROOT / "ergon_core", + ROOT / "ergon_cli", + ROOT / "ergon_builtins", + ROOT / "tests", + ) + for path in base.rglob("*.py") + if "__pycache__" not in path.parts and path != Path(__file__).resolve() + ] + checked_paths.extend(path for path in CONFIG_REFERENCE_FILES if path.exists()) + + stale_references = ( + ".".join(("ergon_core", "core", "runtime", "events")), + "/".join(("core", "runtime", "events")), + ) + + offenders: list[str] = [] + for path in checked_paths: + text = path.read_text() + for snippet in stale_references: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} references {snippet!r}") + + assert offenders == [] + + +def test_context_stream_has_single_discriminated_part_union() -> None: + generation = ROOT / "ergon_core/ergon_core/core/domain/generation/context_parts.py" + event_payloads = ROOT / "ergon_core/ergon_core/core/persistence/context/event_payloads.py" + + generation_text = generation.read_text() + event_payloads_text = event_payloads.read_text() + + assert "ContextPart = Annotated[" in generation_text + old_generation_names = ( + "Generation" + "Turn", + "ModelRequest" + "Part", + "ModelResponse" + "Part", + ) + old_payload_names = ( + "SystemPrompt" + "Payload", + "AssistantText" + "Payload", + "ToolCall" + "Payload", + ) + + for name in old_generation_names: + assert name not in generation_text + for name in old_payload_names: + assert name not in event_payloads_text + + +def test_generation_provider_resolution_does_not_live_in_core() -> None: + try: + spec = importlib.util.find_spec("ergon_core.core.providers.generation") + except ModuleNotFoundError: + spec = None + assert spec is None + + +def test_workflow_propagation_does_not_live_in_execution_package() -> None: + execution_package = ".".join(("ergon_core", "core", "runtime", "execution")) + try: + package_spec = importlib.util.find_spec(execution_package) + except ModuleNotFoundError: + package_spec = None + assert package_spec is None + + try: + propagation_spec = importlib.util.find_spec(f"{execution_package}.propagation") + except ModuleNotFoundError: + propagation_spec = None + assert propagation_spec is None + + +def test_graph_domain_modules_do_not_live_in_services_package() -> None: + moved_modules = ( + "graph_dto", + "graph_lookup", + "graph_repository", + "workflow_propagation_service", + ) + for module in moved_modules: + try: + old_spec = importlib.util.find_spec(f"ergon_core.core.runtime.services.{module}") + except ModuleNotFoundError: + old_spec = None + assert old_spec is None + + for module in ( + "models", + "lookup", + "repository", + "propagation", + ): + assert importlib.util.find_spec(f"ergon_core.core.application.graph.{module}") is not None + + +def test_runtime_services_do_not_import_api_schema_modules() -> None: + offenders: list[str] = [] + for path in (ROOT / "ergon_core/ergon_core/core/runtime").rglob("*.py"): + text = path.read_text() + if "ergon_core.core.rest_api.schemas" in text or "ergon_core.core.rest_api.runs" in text: + offenders.append(str(path.relative_to(ROOT))) + + assert offenders == [] + + +def test_definition_and_composition_services_do_not_live_in_runtime_services() -> None: + old_modules = ( + "ergon_core.core.runtime.services.experiment_validation_service", + "ergon_core.core.runtime.services.experiment_persistence_service", + "ergon_core.core.runtime.services.experiment_definition_service", + "ergon_core.core.runtime.services.experiment_schemas", + ) + for module_name in old_modules: + try: + old_spec = importlib.util.find_spec(module_name) + except ModuleNotFoundError: + old_spec = None + assert old_spec is None + + new_modules = ( + "ergon_core.core.domain.experiments.validation", + "ergon_core.core.application.experiments.definition_writer", + "ergon_core.core.application.experiments.service", + "ergon_core.core.application.experiments.models", + ) + for module_name in new_modules: + assert importlib.util.find_spec(module_name) is not None + + +def test_runtime_services_package_no_longer_contains_domain_modules() -> None: + services_dir = ROOT / "ergon_core/ergon_core/core/runtime/services" + remaining = sorted( + path.name + for path in services_dir.glob("*.py") + if path.name != "__init__.py" + ) + + assert remaining == [] + + +def test_runtime_errors_are_domain_local() -> None: + old_errors_dir = ROOT / "ergon_core/ergon_core/core/runtime/errors" + assert not old_errors_dir.exists() + + for module_name in ( + "ergon_core.core.application.graph.errors", + "ergon_core.core.application.tasks.errors", + "ergon_core.core.application.workflows.errors", + "ergon_core.core.application.evaluation.errors", + "ergon_core.core.application.read_models.errors", + "ergon_core.core.infrastructure.inngest.errors", + ): + assert importlib.util.find_spec(module_name) is not None + + offenders: list[str] = [] + for path in (ROOT / "ergon_core/ergon_core").rglob("*.py"): + text = path.read_text() + if "ergon_core.core.runtime.errors" in text: + offenders.append(str(path.relative_to(ROOT))) + + assert offenders == [] + + +def test_runtime_domain_contract_files_use_consistent_names() -> None: + runtime_dir = ROOT / "ergon_core/ergon_core/core/runtime" + forbidden_suffixes = ("_dto.py", "_models.py", "_schemas.py") + offenders = sorted( + str(path.relative_to(ROOT)) + for path in runtime_dir.rglob("*.py") + if path.name.endswith(forbidden_suffixes) + ) + + assert offenders == [] + + +def test_task_latest_execution_selection_lives_in_task_repository() -> None: + queries_path = ROOT / "ergon_core/ergon_core/core/persistence/queries.py" + repository_path = ROOT / "ergon_core/ergon_core/core/application/tasks/repository.py" + + assert not queries_path.exists() + assert "def latest_for_definition_task" in repository_path.read_text() + + +def test_runtime_and_builtins_do_not_use_task_execution_query_bag_for_domain_reads() -> None: + offenders: list[str] = [] + for base in ( + ROOT / "ergon_core/ergon_core/core/runtime", + ROOT / "ergon_builtins/ergon_builtins", + ): + for path in base.rglob("*.py"): + text = path.read_text() + if ( + "queries.task_executions.list_children_of" in text + or "queries.task_executions.get_task_payload" in text + or "queries.definitions.get_task_with_instance" in text + ): + offenders.append(str(path.relative_to(ROOT))) + + assert offenders == [] + + +def test_resource_viewer_limits_live_with_read_model_resources() -> None: + api_path = ROOT / "ergon_core/ergon_core/core/rest_api/runs.py" + resource_path = ROOT / "ergon_core/ergon_core/core/application/read_models/resources.py" + + assert "_RESOURCE_CONTENT_MAX_BYTES" not in api_path.read_text() + assert "RESOURCE_CONTENT_MAX_BYTES" in resource_path.read_text() + + +def test_task_lifecycle_has_one_front_door_service() -> None: + old_modules = ( + "ergon_core.core.application.tasks.cancellation", + "ergon_core.core.application.tasks.blocking", + ) + for module_name in old_modules: + try: + spec = importlib.util.find_spec(module_name) + except ModuleNotFoundError: + spec = None + assert spec is None + + management = ROOT / "ergon_core/ergon_core/core/application/tasks/management.py" + text = management.read_text() + assert "def cancel_orphans(" in text + assert "def block_pending_descendants(" in text + + +def test_cohort_read_model_has_one_front_door_service() -> None: + old_module = "ergon_core.core.application.read_models.cohort_stats" + try: + spec = importlib.util.find_spec(old_module) + except ModuleNotFoundError: + spec = None + assert spec is None + + cohorts = ROOT / "ergon_core/ergon_core/core/application/read_models/cohorts.py" + assert "def recompute(" in cohorts.read_text() + + +def test_workflow_lifecycle_has_one_front_door_service() -> None: + old_modules = ( + "ergon_core.core.application.workflows.initialization", + "ergon_core.core.application.workflows.finalization", + "ergon_core.core.application.workflows.propagation", + ) + for module_name in old_modules: + try: + spec = importlib.util.find_spec(module_name) + except ModuleNotFoundError: + spec = None + assert spec is None + + workflow_service = ROOT / "ergon_core/ergon_core/core/application/workflows/service.py" + text = workflow_service.read_text() + for method_name in ("initialize", "propagate", "propagate_failure", "finalize"): + assert f"def {method_name}(" in text + + +def test_evaluation_workflow_has_one_front_door_service() -> None: + old_modules = ( + "ergon_core.core.application.evaluation.dispatch", + "ergon_core.core.application.evaluation.rubric", + "ergon_core.core.application.evaluation.persistence", + ) + for module_name in old_modules: + try: + spec = importlib.util.find_spec(module_name) + except ModuleNotFoundError: + spec = None + assert spec is None + + service = ROOT / "ergon_core/ergon_core/core/application/evaluation/service.py" + text = service.read_text() + for method_name in ("prepare_dispatch", "evaluate", "persist_success", "persist_failure"): + assert f"def {method_name}(" in text + + +def test_persistence_layer_does_not_expose_domain_query_bag_or_runtime_context_service() -> None: + assert not (ROOT / "ergon_core/ergon_core/core/persistence/queries.py").exists() + assert not (ROOT / "ergon_core/ergon_core/core/persistence/context/repository.py").exists() + + offenders: list[str] = [] + checked_paths = [ + path + for base in ( + ROOT / "ergon_core/ergon_core", + ROOT / "ergon_builtins/ergon_builtins", + ) + for path in base.rglob("*.py") + if "__pycache__" not in path.parts and path != Path(__file__).resolve() + ] + checked_paths.extend(path for path in CONFIG_REFERENCE_FILES if path.exists()) + + dotted_query = ".".join(("ergon_core", "core", "persistence", "queries")) + dotted_context_repository = ".".join( + ("ergon_core", "core", "persistence", "context", "repository") + ) + path_query = "/".join(("ergon_core", "ergon_core", "core", "persistence", "queries.py")) + suffix_query = "/".join(("persistence", "queries.py")) + path_context_repository = "/".join( + ( + "ergon_core", + "ergon_core", + "core", + "persistence", + "context", + "repository.py", + ) + ) + suffix_context_repository = "/".join(("persistence", "context", "repository.py")) + + for path in checked_paths: + text = path.read_text() + if dotted_query in text or path_query in text or suffix_query in text: + offenders.append(str(path.relative_to(ROOT))) + if ( + dotted_context_repository in text + or path_context_repository in text + or suffix_context_repository in text + ): + offenders.append(str(path.relative_to(ROOT))) + + assert offenders == [] + + +def test_telemetry_repository_is_row_storage_not_evaluation_summary_service() -> None: + repository_path = ROOT / "ergon_core/ergon_core/core/persistence/telemetry/repositories.py" + text = repository_path.read_text() + + assert "refresh_run_evaluation_summary" not in text + assert "aggregate_evaluation_scores" not in text + + +def test_experiment_lifecycle_has_one_front_door_service() -> None: + service_path = ROOT / "ergon_core/ergon_core/core/application/experiments/service.py" + service_text = service_path.read_text() + + assert "class ExperimentService" in service_text + for method_name in ("define_benchmark_experiment", "persist_definition", "run_experiment"): + assert f"def {method_name}(" in service_text + + forbidden_class_names = ( + "class ExperimentDefinitionService", + "class ExperimentPersistenceService", + "class ExperimentLaunchService", + ) + for path in ( + service_path, + ROOT / "ergon_core/ergon_core/core/application/experiments/definition_writer.py", + ROOT / "ergon_core/ergon_core/core/application/experiments/launch.py", + ): + text = path.read_text() + for class_name in forbidden_class_names: + assert class_name not in text diff --git a/tests/unit/architecture/test_model_field_descriptions.py b/ergon_core/tests/unit/architecture/test_model_field_descriptions.py similarity index 93% rename from tests/unit/architecture/test_model_field_descriptions.py rename to ergon_core/tests/unit/architecture/test_model_field_descriptions.py index 887c47e2..609d6b30 100644 --- a/tests/unit/architecture/test_model_field_descriptions.py +++ b/ergon_core/tests/unit/architecture/test_model_field_descriptions.py @@ -1,7 +1,7 @@ """Guards for model field docs that must survive schema export.""" -from ergon_core.core.dashboard.event_contracts import DashboardContextEventEvent -from ergon_core.core.generation import ( +from ergon_core.core.infrastructure.dashboard.event_contracts import DashboardContextEventEvent +from ergon_core.core.domain.generation.context_parts import ( AssistantTextPart, ContextPartChunkLog, ThinkingPart, @@ -16,7 +16,7 @@ RunGraphNode, ) from ergon_core.core.persistence.telemetry.models import RunResource -from ergon_core.core.runtime.services.graph_dto import ( +from ergon_core.core.application.graph.models import ( GraphAnnotationDto, GraphEdgeDto, GraphMutationRecordDto, diff --git a/tests/unit/architecture/test_no_test_logic_in_core.py b/ergon_core/tests/unit/architecture/test_no_test_logic_in_core.py similarity index 88% rename from tests/unit/architecture/test_no_test_logic_in_core.py rename to ergon_core/tests/unit/architecture/test_no_test_logic_in_core.py index 3d25feb7..a78d54af 100644 --- a/tests/unit/architecture/test_no_test_logic_in_core.py +++ b/ergon_core/tests/unit/architecture/test_no_test_logic_in_core.py @@ -1,11 +1,11 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[3] +ROOT = Path(__file__).resolve().parents[4] CORE = ROOT / "ergon_core" / "ergon_core" / "core" ALLOWED_FILES = { - CORE / "api" / "test_harness.py", - CORE / "settings.py", + CORE / "rest_api" / "test_harness.py", + CORE / "shared" / "settings.py", } FORBIDDEN_IMPORT_SNIPPETS = ( @@ -51,7 +51,7 @@ def test_core_does_not_define_or_branch_on_stub_sandbox_terms() -> None: def test_core_task_execution_does_not_mint_placeholder_sandbox_ids() -> None: - path = CORE / "runtime" / "inngest" / "execute_task.py" + path = CORE / "application" / "jobs" / "execute_task.py" text = path.read_text() assert "StubSandboxManager" not in text diff --git a/ergon_core/tests/unit/architecture/test_persistence_boundaries.py b/ergon_core/tests/unit/architecture/test_persistence_boundaries.py new file mode 100644 index 00000000..a4e542b3 --- /dev/null +++ b/ergon_core/tests/unit/architecture/test_persistence_boundaries.py @@ -0,0 +1,49 @@ +"""Architecture guards for persistence boundaries.""" + +from pathlib import Path + +FORBIDDEN_PATTERNS = ( + "get_session(", + "session.exec(", + "session.get(", + "select(", +) + +ALLOWLIST = { + # Test harness endpoints are explicitly debug/dev-only and expose raw state + # for rollout inspection. They should remain isolated behind settings gates. + Path("ergon_core/ergon_core/core/rest_api/test_harness.py"), + # Context events are streamed from the application job as each model turn + # lands; this legacy path is intentionally deferred until the context + # event repository owns its transaction boundary. + Path("ergon_core/ergon_core/core/application/jobs/worker_execute.py"), + # Workflow lifecycle jobs still own small transactional updates. + # New jobs should use repositories/services instead. + Path("ergon_core/ergon_core/core/application/jobs/start_workflow.py"), + Path("ergon_core/ergon_core/core/application/jobs/run_cleanup.py"), + Path("ergon_core/ergon_core/core/application/jobs/cleanup_cancelled_task.py"), + Path("ergon_core/ergon_core/core/application/jobs/cancel_orphan_subtasks.py"), + Path("ergon_core/ergon_core/core/application/jobs/complete_workflow.py"), + Path("ergon_core/ergon_core/core/application/jobs/sandbox_setup.py"), + Path("ergon_core/ergon_core/core/application/jobs/fail_workflow.py"), +} + +CHECKED_ROOTS = ( + Path("ergon_core/ergon_core/core/rest_api"), + Path("ergon_core/ergon_core/core/infrastructure/dashboard"), + Path("ergon_core/ergon_core/core/infrastructure/inngest/handlers"), +) + + +def test_db_access_stays_out_of_api_dashboard_and_inngest_layers() -> None: + offenders: list[str] = [] + for root in CHECKED_ROOTS: + for path in root.rglob("*.py"): + if path in ALLOWLIST: + continue + text = path.read_text() + matches = [pattern for pattern in FORBIDDEN_PATTERNS if pattern in text] + if matches: + offenders.append(f"{path}: {', '.join(matches)}") + + assert offenders == [] diff --git a/ergon_core/tests/unit/architecture/test_public_api_boundaries.py b/ergon_core/tests/unit/architecture/test_public_api_boundaries.py new file mode 100644 index 00000000..56006742 --- /dev/null +++ b/ergon_core/tests/unit/architecture/test_public_api_boundaries.py @@ -0,0 +1,517 @@ +"""Architecture guards for the student-facing public API boundary.""" + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] + +REMOVED_PUBLIC_API_MODULES = ( + "ergon_core.api.generation", + "ergon_core.api.json_types", + "ergon_core.api.run_resource", + "ergon_core.api.criterion_runtime", + "ergon_core.api.dependencies", + "ergon_core.api.types", +) + +FORBIDDEN_IMPORT_SNIPPETS = ( + "from ergon_core.api.generation import", + "from ergon_core.api.json_types import", + "from ergon_core.api.run_resource import", + "from ergon_core.api.criterion_runtime import", + "from ergon_core.api.dependencies import", + "from ergon_core.api.types import", +) + +CHECKED_ROOTS = ( + ROOT / "ergon_builtins", + ROOT / "ergon_cli", + ROOT / "ergon_core" / "ergon_core" / "core", +) + +PYTHON_DOMAIN_ROOTS = ( + ROOT / "ergon_builtins" / "ergon_builtins", + ROOT / "ergon_cli" / "ergon_cli", + ROOT / "ergon_core" / "ergon_core", + ROOT / "ergon_infra" / "ergon_infra", +) + +EXPORT_FACADE_BOUNDARY_ROOTS = ( + ROOT / "ergon_core" / "ergon_core" / "api", + ROOT / "ergon_core" / "ergon_core" / "core" / "domain", + ROOT / "ergon_core" / "ergon_core" / "core" / "shared", +) + +INTERNAL_API_REFERENCE_ROOTS = ( + ROOT / "ergon_core", + ROOT / "ergon_cli", + ROOT / "ergon_builtins", + ROOT / "tests", +) + +INTERNAL_API_REFERENCE_FILES = ( + ROOT / "pyproject.toml", + ROOT / "Dockerfile", + ROOT / "docker-compose.yml", +) + +STALE_INTERNAL_API_SNIPPETS = ( + "ergon_core.core.api", + "ergon_core/ergon_core/core/api", +) + +OLD_CORE_DOMAIN_IMPORT_SNIPPETS = ( + "ergon_core.core.composition", + "ergon_core.core.generation", + "ergon_core.core.json_types", + "ergon_core.core.settings", + "ergon_core.core.utils", +) + +OLD_EXPERIMENT_APPLICATION_REFERENCE_SNIPPETS = ( + ".".join(("ergon_core", "core", "definitions")), + ".".join(("ergon_core", "core", "runtime", "workflows", "launch")), + "/".join(("ergon_core", "ergon_core", "core", "definitions")), + "/".join( + ( + "ergon_core", + "ergon_core", + "core", + "runtime", + "workflows", + "launch.py", + ) + ), +) + +OLD_APPLICATION_RUNTIME_REFERENCE_SNIPPETS = ( + ".".join(("ergon_core", "core", "runtime", "execution")), + ".".join(("ergon_core", "core", "runtime", "workflows")), + ".".join(("ergon_core", "core", "runtime", "graph")), + ".".join(("ergon_core", "core", "runtime", "tasks")), + ".".join(("ergon_core", "core", "runtime", "evaluation")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "execution")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "workflows")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "graph")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "tasks")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "evaluation")), +) + +OLD_RUNTIME_READ_CONTEXT_RESOURCE_REFERENCE_SNIPPETS = ( + ".".join(("ergon_core", "core", "runtime", "read_models")), + ".".join(("ergon_core", "core", "runtime", "context_events")), + ".".join(("ergon_core", "core", "runtime", "output_extraction")), + ".".join(("ergon_core", "core", "runtime", "resources")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "read_models")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "context_events")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "output_extraction")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "resources")), +) + +OLD_RUNTIME_INNGEST_REFERENCE_SNIPPETS = ( + ".".join(("ergon_core", "core", "runtime", "inngest")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "inngest")), +) + +OLD_INFRASTRUCTURE_REFERENCE_SNIPPETS = ( + ".".join(("ergon_core", "core", "sandbox")), + ".".join(("ergon_core", "core", "dashboard")), + ".".join(("ergon_core", "core", "runtime", "tracing")), + ".".join(("ergon_core", "core", "runtime", "dependencies")), + "/".join(("ergon_core", "ergon_core", "core", "sandbox")), + "/".join(("ergon_core", "ergon_core", "core", "dashboard")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "tracing")), + "/".join(("ergon_core", "ergon_core", "core", "runtime", "dependencies.py")), +) + + +def test_runtime_and_builtin_code_do_not_import_core_types_through_public_api() -> None: + offenders: list[str] = [] + for root in CHECKED_ROOTS: + for path in root.rglob("*.py"): + text = path.read_text() + for snippet in FORBIDDEN_IMPORT_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} imports via {snippet!r}") + + assert offenders == [] + + +def test_deleted_public_api_facade_modules_stay_deleted() -> None: + restored = [ + module_name + for module_name in REMOVED_PUBLIC_API_MODULES + if importlib.util.find_spec(module_name) is not None + ] + + assert restored == [] + + +def test_internal_http_api_is_named_rest_api_not_core_api() -> None: + core_root = ROOT / "ergon_core" / "ergon_core" / "core" + + assert not (core_root / "api").exists() + assert (core_root / "rest_api").exists() + + +def test_code_and_config_do_not_reference_old_internal_core_api() -> None: + checked_paths = [ + path + for root in INTERNAL_API_REFERENCE_ROOTS + for path in root.rglob("*.py") + if "__pycache__" not in path.parts + and path != Path(__file__).resolve() + ] + checked_paths.extend(path for path in INTERNAL_API_REFERENCE_FILES if path.exists()) + + offenders: list[str] = [] + for path in checked_paths: + text = path.read_text() + for snippet in STALE_INTERNAL_API_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} references {snippet!r}") + + assert offenders == [] + + +def test_shared_and_domain_primitives_stay_in_new_core_layout() -> None: + core_root = ROOT / "ergon_core" / "ergon_core" / "core" + + for old_path in ( + core_root / "composition", + core_root / "generation.py", + core_root / "json_types.py", + core_root / "settings.py", + core_root / "utils.py", + ): + assert not old_path.exists() + + for new_path in ( + core_root / "domain" / "experiments" / "__init__.py", + core_root / "domain" / "generation" / "context_parts.py", + core_root / "shared" / "json_types.py", + core_root / "shared" / "settings.py", + core_root / "shared" / "utils.py", + ): + assert new_path.exists() + + +def test_code_does_not_import_old_core_domain_paths() -> None: + offenders: list[str] = [] + checked_paths = [ + path + for domain_root in PYTHON_DOMAIN_ROOTS + for path in domain_root.rglob("*.py") + if "__pycache__" not in path.parts and path != Path(__file__).resolve() + ] + checked_paths.extend( + path + for root in (ROOT / "tests",) + for path in root.rglob("*.py") + if "__pycache__" not in path.parts and path != Path(__file__).resolve() + ) + + for path in checked_paths: + text = path.read_text() + for snippet in OLD_CORE_DOMAIN_IMPORT_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} references {snippet!r}") + + assert offenders == [] + + +def test_experiment_application_cluster_stays_in_new_core_layout() -> None: + core_root = ROOT / "ergon_core" / "ergon_core" / "core" + + assert not (core_root / "definitions").exists() + assert not (core_root / "runtime" / "workflows" / "launch.py").exists() + for new_path in ( + core_root / "application" / "experiments" / "__init__.py", + core_root / "application" / "experiments" / "service.py", + core_root / "application" / "experiments" / "models.py", + core_root / "application" / "experiments" / "repository.py", + core_root / "application" / "experiments" / "definition_writer.py", + core_root / "application" / "experiments" / "launch.py", + ): + assert new_path.exists() + + offenders: list[str] = [] + checked_paths = [ + path + for root in INTERNAL_API_REFERENCE_ROOTS + for path in root.rglob("*.py") + if "__pycache__" not in path.parts and path != Path(__file__).resolve() + ] + checked_paths.extend(path for path in INTERNAL_API_REFERENCE_FILES if path.exists()) + for path in checked_paths: + text = path.read_text() + for snippet in OLD_EXPERIMENT_APPLICATION_REFERENCE_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} references {snippet!r}") + + assert offenders == [] + + +def test_application_clusters_stay_out_of_runtime_layout() -> None: + core_root = ROOT / "ergon_core" / "ergon_core" / "core" + + for old_dir in ( + core_root / "runtime" / "execution", + core_root / "runtime" / "workflows", + core_root / "runtime" / "graph", + core_root / "runtime" / "tasks", + core_root / "runtime" / "evaluation", + ): + assert not old_dir.exists() + + for new_path in ( + core_root / "application" / "workflows" / "__init__.py", + core_root / "application" / "workflows" / "service.py", + core_root / "application" / "workflows" / "orchestration.py", + core_root / "application" / "workflows" / "runs.py", + core_root / "application" / "workflows" / "models.py", + core_root / "application" / "workflows" / "errors.py", + core_root / "application" / "graph" / "__init__.py", + core_root / "application" / "graph" / "repository.py", + core_root / "application" / "graph" / "propagation.py", + core_root / "application" / "graph" / "traversal.py", + core_root / "application" / "graph" / "lookup.py", + core_root / "application" / "graph" / "models.py", + core_root / "application" / "graph" / "errors.py", + core_root / "application" / "tasks" / "__init__.py", + core_root / "application" / "tasks" / "service.py", + core_root / "application" / "tasks" / "execution.py", + core_root / "application" / "tasks" / "management.py", + core_root / "application" / "tasks" / "inspection.py", + core_root / "application" / "tasks" / "cleanup.py", + core_root / "application" / "tasks" / "repository.py", + core_root / "application" / "tasks" / "models.py", + core_root / "application" / "tasks" / "errors.py", + core_root / "application" / "evaluation" / "__init__.py", + core_root / "application" / "evaluation" / "service.py", + core_root / "application" / "evaluation" / "executors.py", + core_root / "application" / "evaluation" / "inngest_executor.py", + core_root / "application" / "evaluation" / "criterion_runtime.py", + core_root / "application" / "evaluation" / "scoring.py", + core_root / "application" / "evaluation" / "protocols.py", + core_root / "application" / "evaluation" / "models.py", + core_root / "application" / "evaluation" / "errors.py", + ): + assert new_path.exists() + + offenders: list[str] = [] + checked_paths = [ + path + for root in INTERNAL_API_REFERENCE_ROOTS + for path in root.rglob("*.py") + if "__pycache__" not in path.parts and path != Path(__file__).resolve() + ] + checked_paths.extend(path for path in INTERNAL_API_REFERENCE_FILES if path.exists()) + for path in checked_paths: + text = path.read_text() + for snippet in OLD_APPLICATION_RUNTIME_REFERENCE_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} references {snippet!r}") + + assert offenders == [] + + +def test_read_context_and_resource_modules_stay_in_application_layout() -> None: + core_root = ROOT / "ergon_core" / "ergon_core" / "core" + + for old_path in ( + core_root / "runtime" / "read_models", + core_root / "runtime" / "context_events.py", + core_root / "runtime" / "output_extraction.py", + core_root / "runtime" / "resources.py", + ): + assert not old_path.exists() + + for new_path in ( + core_root / "application" / "read_models" / "__init__.py", + core_root / "application" / "read_models" / "models.py", + core_root / "application" / "read_models" / "runs.py", + core_root / "application" / "read_models" / "run_snapshot.py", + core_root / "application" / "read_models" / "experiments.py", + core_root / "application" / "read_models" / "cohorts.py", + core_root / "application" / "read_models" / "resources.py", + core_root / "application" / "read_models" / "errors.py", + core_root / "application" / "communication" / "__init__.py", + core_root / "application" / "communication" / "service.py", + core_root / "application" / "communication" / "models.py", + core_root / "application" / "communication" / "errors.py", + core_root / "application" / "context" / "__init__.py", + core_root / "application" / "context" / "events.py", + core_root / "application" / "context" / "output_extraction.py", + core_root / "application" / "resources" / "__init__.py", + core_root / "application" / "resources" / "models.py", + core_root / "application" / "resources" / "repository.py", + ): + assert new_path.exists() + + checked_paths = [ + path + for root in INTERNAL_API_REFERENCE_ROOTS + for path in root.rglob("*.py") + if "__pycache__" not in path.parts and path != Path(__file__).resolve() + ] + checked_paths.extend(path for path in INTERNAL_API_REFERENCE_FILES if path.exists()) + + offenders: list[str] = [] + for path in checked_paths: + text = path.read_text() + for snippet in OLD_RUNTIME_READ_CONTEXT_RESOURCE_REFERENCE_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} references {snippet!r}") + + assert offenders == [] + + +def _inngest_job_boundary_offenders(core_root: Path) -> list[str]: + allowed_job_infrastructure_imports = ( + "from ergon_core.core.infrastructure.inngest.client import", + "from ergon_core.core.infrastructure.inngest.errors import", + ) + + offenders: list[str] = [] + for path in (core_root / "application" / "jobs").glob("*.py"): + text = path.read_text() + lines = text.splitlines() + if any(line == "import inngest" or line.startswith("from inngest ") for line in lines): + offenders.append(f"{path.relative_to(ROOT)} imports inngest directly") + if "@inngest_client.create_function" in text: + offenders.append(f"{path.relative_to(ROOT)} owns an Inngest decorator") + if "ergon_core.core.infrastructure.inngest.handlers" in text: + offenders.append(f"{path.relative_to(ROOT)} imports infrastructure handlers") + if "ergon_core.core.infrastructure.inngest.contracts" in text: + offenders.append(f"{path.relative_to(ROOT)} imports infrastructure contracts") + offenders.extend( + f"{path.relative_to(ROOT)} has unsupported Inngest infrastructure import: {line}" + for line in lines + if "ergon_core.core.infrastructure.inngest." in line + and not line.startswith(allowed_job_infrastructure_imports) + ) + + return offenders + + +def test_inngest_jobs_and_handlers_stay_split() -> None: + core_root = ROOT / "ergon_core" / "ergon_core" / "core" + + assert not (core_root / "runtime" / "inngest").exists() + + for new_path in ( + core_root / "application" / "jobs" / "__init__.py", + core_root / "application" / "jobs" / "models.py", + core_root / "infrastructure" / "inngest" / "__init__.py", + core_root / "infrastructure" / "inngest" / "client.py", + core_root / "infrastructure" / "inngest" / "registry.py", + core_root / "infrastructure" / "inngest" / "contracts.py", + core_root / "infrastructure" / "inngest" / "errors.py", + core_root / "infrastructure" / "inngest" / "handlers" / "__init__.py", + ): + assert new_path.exists() + + offenders = _inngest_job_boundary_offenders(core_root) + + registry_text = (core_root / "infrastructure" / "inngest" / "registry.py").read_text() + assert "ergon_core.core.infrastructure.inngest.handlers" in registry_text + assert "ergon_core.core.application.jobs" not in registry_text + + checked_paths = [ + path + for root in INTERNAL_API_REFERENCE_ROOTS + for path in root.rglob("*.py") + if "__pycache__" not in path.parts and path != Path(__file__).resolve() + ] + for path in checked_paths: + text = path.read_text() + for snippet in OLD_RUNTIME_INNGEST_REFERENCE_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} references {snippet!r}") + + assert offenders == [] + + +def test_sandbox_dashboard_tracing_and_dependencies_stay_in_infrastructure() -> None: + core_root = ROOT / "ergon_core" / "ergon_core" / "core" + + for old_path in ( + core_root / "sandbox", + core_root / "dashboard", + core_root / "runtime" / "tracing", + core_root / "runtime" / "dependencies.py", + ): + assert not old_path.exists() + + for new_path in ( + core_root / "infrastructure" / "sandbox" / "__init__.py", + core_root / "infrastructure" / "sandbox" / "manager.py", + core_root / "infrastructure" / "sandbox" / "lifecycle.py", + core_root / "infrastructure" / "sandbox" / "resource_publisher.py", + core_root / "infrastructure" / "sandbox" / "instrumentation.py", + core_root / "infrastructure" / "sandbox" / "event_sink.py", + core_root / "infrastructure" / "sandbox" / "errors.py", + core_root / "infrastructure" / "sandbox" / "utils.py", + core_root / "infrastructure" / "dashboard" / "__init__.py", + core_root / "infrastructure" / "dashboard" / "emitter.py", + core_root / "infrastructure" / "dashboard" / "provider.py", + core_root / "infrastructure" / "dashboard" / "event_contracts.py", + core_root / "infrastructure" / "tracing" / "__init__.py", + core_root / "infrastructure" / "tracing" / "attributes.py", + core_root / "infrastructure" / "tracing" / "contexts.py", + core_root / "infrastructure" / "tracing" / "ids.py", + core_root / "infrastructure" / "tracing" / "noop.py", + core_root / "infrastructure" / "tracing" / "otel.py", + core_root / "infrastructure" / "tracing" / "sinks.py", + core_root / "infrastructure" / "tracing" / "types.py", + core_root / "infrastructure" / "dependencies.py", + ): + assert new_path.exists() + + checked_paths = [ + path + for root in (*INTERNAL_API_REFERENCE_ROOTS, ROOT / "scripts") + for path in root.rglob("*.py") + if "__pycache__" not in path.parts and path != Path(__file__).resolve() + ] + checked_paths.extend(path for path in INTERNAL_API_REFERENCE_FILES if path.exists()) + + offenders: list[str] = [] + for path in checked_paths: + text = path.read_text() + for snippet in OLD_INFRASTRUCTURE_REFERENCE_SNIPPETS: + if snippet in text: + offenders.append(f"{path.relative_to(ROOT)} references {snippet!r}") + + assert offenders == [] + + +def test_python_domain_leaf_modules_do_not_define_export_facades() -> None: + offenders = [ + path.relative_to(ROOT) + for boundary_root in EXPORT_FACADE_BOUNDARY_ROOTS + for path in boundary_root.rglob("*.py") + if path.name != "__init__.py" and "__all__" in path.read_text() + ] + + assert offenders == [] + + +def test_e2e_tests_do_not_import_private_core_repositories() -> None: + e2e_dir = ROOT / "tests" / "e2e" + forbidden = ( + "ergon_core.core.persistence.", + "ergon_core.core.runtime.tasks.repository", + "ergon_core.core.runtime.evaluation.persistence", + "ergon_core.core.runtime.inngest.", + ) + + offenders: list[str] = [] + for path in e2e_dir.rglob("*.py"): + text = path.read_text() + for needle in forbidden: + if needle in text: + offenders.append(f"{path.relative_to(ROOT)} imports {needle!r}") + + assert offenders == [] diff --git a/ergon_core/tests/unit/architecture/test_public_api_target_structure.py b/ergon_core/tests/unit/architecture/test_public_api_target_structure.py new file mode 100644 index 00000000..6711e326 --- /dev/null +++ b/ergon_core/tests/unit/architecture/test_public_api_target_structure.py @@ -0,0 +1,109 @@ +"""Architecture guards for the Phase 1 public API target structure.""" + +import importlib +import inspect + + +def test_public_api_root_exports_semantic_authoring_names_only() -> None: + public_api = importlib.import_module("ergon_core.api") + + expected = { + "Benchmark", + "BenchmarkRequirements", + "Task", + "EmptyTaskPayload", + "Worker", + "WorkerContext", + "WorkerOutput", + "WorkerStreamItem", + "Criterion", + "CriterionContext", + "CriterionOutcome", + "ScoreScale", + "CriterionEvidence", + "EvidenceMessage", + "Rubric", + "TaskEvaluationResult", + "CriterionCheckError", + "ComponentRegistry", + "WorkerFactory", + "registry", + } + retired = { + "BenchmarkTask", + "BenchmarkDeps", + "EvaluationContext", + "CriterionResult", + "CriterionScoreSpec", + "CriterionObservation", + "CriterionObservationMessage", + "CriteriaCheckError", + "Experiment", + "WorkerSpec", + "PersistedExperimentDefinition", + "DefinitionHandle", + } + + assert set(public_api.__all__) == expected + assert all(hasattr(public_api, name) for name in expected) + assert retired.isdisjoint(public_api.__all__) + assert all(not hasattr(public_api, name) for name in retired) + + +def test_semantic_api_clusters_are_importable() -> None: + benchmark = importlib.import_module("ergon_core.api.benchmark") + worker = importlib.import_module("ergon_core.api.worker") + criterion = importlib.import_module("ergon_core.api.criterion") + rubric = importlib.import_module("ergon_core.api.rubric") + + assert benchmark.__all__ == ["Benchmark", "BenchmarkRequirements", "Task", "EmptyTaskPayload"] + assert worker.__all__ == ["Worker", "WorkerContext", "WorkerOutput", "WorkerStreamItem"] + assert criterion.__all__ == [ + "Criterion", + "CriterionContext", + "CriterionOutcome", + "ScoreScale", + "CriterionEvidence", + "EvidenceMessage", + ] + assert rubric.__all__ == ["Evaluator", "Rubric", "TaskEvaluationResult"] + + +def test_core_composition_owns_experiment_worker_spec_and_definition_handle() -> None: + composition = importlib.import_module("ergon_core.core.domain.experiments") + + assert composition.__all__ == ["DefinitionHandle", "Experiment", "WorkerSpec"] + assert hasattr(composition, "DefinitionHandle") + assert hasattr(composition, "Experiment") + assert hasattr(composition, "WorkerSpec") + + +def test_public_worker_module_does_not_import_persistence_or_sessions() -> None: + worker_module = importlib.import_module("ergon_core.api.worker.worker") + source = inspect.getsource(worker_module) + + forbidden = ( + "ergon_core.core.persistence", + "ContextEventService", + "get_session", + "sqlmodel", + ) + assert all(snippet not in source for snippet in forbidden) + + +def test_criterion_context_hides_runtime_protocol_field() -> None: + context_module = importlib.import_module("ergon_core.api.criterion.context") + context_fields = context_module.CriterionContext.model_fields + + assert "runtime" not in context_fields + assert hasattr(context_module.CriterionContext, "execute_code") + + +def test_public_result_models_do_not_import_core_json_types() -> None: + modules = [ + importlib.import_module("ergon_core.api.worker.results"), + importlib.import_module("ergon_core.api.criterion.results"), + importlib.import_module("ergon_core.api.rubric.results"), + ] + + assert all("ergon_core.core.shared.json_types" not in inspect.getsource(module) for module in modules) diff --git a/tests/unit/architecture/test_smoke_fixture_package_boundary.py b/ergon_core/tests/unit/architecture/test_smoke_fixture_package_boundary.py similarity index 58% rename from tests/unit/architecture/test_smoke_fixture_package_boundary.py rename to ergon_core/tests/unit/architecture/test_smoke_fixture_package_boundary.py index f8542ee8..3f31db11 100644 --- a/tests/unit/architecture/test_smoke_fixture_package_boundary.py +++ b/ergon_core/tests/unit/architecture/test_smoke_fixture_package_boundary.py @@ -5,7 +5,7 @@ def test_runtime_entrypoints_do_not_import_tests_smoke_fixtures() -> None: entrypoints = ( - Path("ergon_core/ergon_core/core/api/app.py"), + Path("ergon_core/ergon_core/core/rest_api/app.py"), Path("ergon_cli/ergon_cli/composition/__init__.py"), ) @@ -14,11 +14,12 @@ def test_runtime_entrypoints_do_not_import_tests_smoke_fixtures() -> None: assert "tests.e2e._fixtures" not in text assert "ergon_core.dev.smoke_fixtures" not in text assert ( - "ergon_core.test_support.smoke_fixtures" - not in Path("ergon_core/ergon_core/core/api/app.py").read_text() + "tests.fixtures.smoke_components" + not in Path("ergon_core/ergon_core/core/rest_api/app.py").read_text() ) -def test_smoke_fixtures_live_in_test_support_package() -> None: - assert Path("ergon_core/ergon_core/test_support/smoke_fixtures").is_dir() +def test_smoke_fixtures_live_in_tests_package() -> None: + assert Path("tests/fixtures/smoke_components").is_dir() + assert not Path("ergon_core/ergon_core/test_support/smoke_fixtures").exists() assert not Path("ergon_core/ergon_core/dev/smoke_fixtures").exists() diff --git a/tests/unit/dashboard/test_communication_threads.py b/ergon_core/tests/unit/dashboard/test_communication_threads.py similarity index 96% rename from tests/unit/dashboard/test_communication_threads.py rename to ergon_core/tests/unit/dashboard/test_communication_threads.py index 5b7b0208..6113748f 100644 --- a/tests/unit/dashboard/test_communication_threads.py +++ b/ergon_core/tests/unit/dashboard/test_communication_threads.py @@ -1,7 +1,7 @@ from uuid import uuid4 -from ergon_core.core.api.runs import _build_communication_threads from ergon_core.core.persistence.telemetry.models import Thread, ThreadMessage +from ergon_core.core.application.read_models.run_snapshot import _build_communication_threads def test_build_communication_threads_populates_summary_and_task_anchors() -> None: diff --git a/tests/unit/dashboard/test_emitter_provider.py b/ergon_core/tests/unit/dashboard/test_emitter_provider.py similarity index 88% rename from tests/unit/dashboard/test_emitter_provider.py rename to ergon_core/tests/unit/dashboard/test_emitter_provider.py index e000b485..4911e82a 100644 --- a/tests/unit/dashboard/test_emitter_provider.py +++ b/ergon_core/tests/unit/dashboard/test_emitter_provider.py @@ -1,7 +1,7 @@ import pytest -from ergon_core.core.dashboard.emitter import DashboardEmitter -from ergon_core.core.dashboard.provider import ( +from ergon_core.core.infrastructure.dashboard.emitter import DashboardEmitter +from ergon_core.core.infrastructure.dashboard.provider import ( get_dashboard_emitter, init_dashboard_emitter, reset_dashboard_emitter, diff --git a/tests/unit/dashboard/test_event_contract_types.py b/ergon_core/tests/unit/dashboard/test_event_contract_types.py similarity index 82% rename from tests/unit/dashboard/test_event_contract_types.py rename to ergon_core/tests/unit/dashboard/test_event_contract_types.py index f03bf30b..c89b9071 100644 --- a/tests/unit/dashboard/test_event_contract_types.py +++ b/ergon_core/tests/unit/dashboard/test_event_contract_types.py @@ -3,17 +3,17 @@ from uuid import uuid4 import pytest -from ergon_core.core.dashboard import emitter as dashboard_emitter_module -from ergon_core.core.dashboard.emitter import DashboardEmitter -from ergon_core.core.api.schemas import ( +from ergon_core.core.application.communication.models import ( RunCommunicationMessageDto, RunCommunicationThreadDto, ) -from ergon_core.core.dashboard.event_contracts import ( +from ergon_core.core.infrastructure.dashboard import emitter as dashboard_emitter_module +from ergon_core.core.infrastructure.dashboard.emitter import DashboardEmitter +from ergon_core.core.infrastructure.dashboard.event_contracts import ( CohortUpdatedEvent, DashboardThreadMessageCreatedEvent, ) -from ergon_core.core.runtime.services.cohort_schemas import CohortSummaryDto +from ergon_core.core.application.read_models.models import CohortSummaryDto def test_thread_message_event_uses_dashboard_dtos() -> None: diff --git a/tests/unit/persistence/test_context_event_repository.py b/ergon_core/tests/unit/persistence/test_context_event_repository.py similarity index 96% rename from tests/unit/persistence/test_context_event_repository.py rename to ergon_core/tests/unit/persistence/test_context_event_repository.py index 0b763839..43f1ef83 100644 --- a/tests/unit/persistence/test_context_event_repository.py +++ b/ergon_core/tests/unit/persistence/test_context_event_repository.py @@ -1,7 +1,7 @@ from uuid import uuid4 import pytest -from ergon_core.core.generation import ( +from ergon_core.core.domain.generation.context_parts import ( AssistantTextPart, ContextPartChunk, ContextPartChunkLog, @@ -11,7 +11,7 @@ UserMessagePart, ) from ergon_core.core.persistence.context.models import RunContextEvent -from ergon_core.core.persistence.context.repository import ContextEventRepository +from ergon_core.core.application.context.events import ContextEventService from ergon_core.core.persistence.definitions.models import ExperimentDefinition from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus @@ -107,7 +107,7 @@ def test_run_context_event_parsed_payload_is_context_part_chunk_log() -> None: async def test_persist_chunk_records_prompt_and_model_output_in_order() -> None: session = _session() run_id, execution_id = _execution_fixture(session) - repo = ContextEventRepository() + repo = ContextEventService() await repo.persist_chunk( session, @@ -146,7 +146,7 @@ async def test_persist_chunk_records_prompt_and_model_output_in_order() -> None: async def test_persist_chunk_tool_result_closes_current_turn() -> None: session = _session() run_id, execution_id = _execution_fixture(session) - repo = ContextEventRepository() + repo = ContextEventService() await repo.persist_chunk( session, diff --git a/tests/unit/api/__init__.py b/ergon_core/tests/unit/registry/__init__.py similarity index 100% rename from tests/unit/api/__init__.py rename to ergon_core/tests/unit/registry/__init__.py diff --git a/ergon_core/tests/unit/registry/test_builtin_pairings.py b/ergon_core/tests/unit/registry/test_builtin_pairings.py new file mode 100644 index 00000000..307cc4c4 --- /dev/null +++ b/ergon_core/tests/unit/registry/test_builtin_pairings.py @@ -0,0 +1,86 @@ +"""Documented built-in benchmark pairings are explicit and registered.""" + +import pytest + +from ergon_core.api.registry import ComponentRegistry + + +CORE_PAIRINGS = [ + { + "benchmark": "minif2f", + "worker": "minif2f-react", + "evaluator": "minif2f-rubric", + "sandbox": "minif2f", + "extras": ("none",), + }, + { + "benchmark": "swebench-verified", + "worker": "swebench-react", + "evaluator": "swebench-rubric", + "sandbox": "swebench-verified", + "extras": ("ergon-builtins[data]",), + }, +] + +DATA_PAIRINGS = [ + { + "benchmark": "gdpeval", + "worker": "gdpeval-react", + "evaluator": "gdpeval-staged-rubric", + "sandbox": "gdpeval", + "extras": ("ergon-builtins[data]",), + }, + { + "benchmark": "researchrubrics", + "worker": "researchrubrics-researcher", + "evaluator": "researchrubrics-rubric", + "sandbox": "researchrubrics", + "extras": ("ergon-builtins[data]",), + }, + { + "benchmark": "researchrubrics-vanilla", + "worker": "researchrubrics-researcher", + "evaluator": "researchrubrics-rubric", + "sandbox": "researchrubrics-vanilla", + "extras": ("ergon-builtins[data]",), + }, +] + + +@pytest.mark.parametrize("pairing", CORE_PAIRINGS) +def test_core_pairings_reference_registered_slugs(pairing: dict[str, object]) -> None: + from ergon_builtins.registry_core import register_core_builtins + + registry = ComponentRegistry() + register_core_builtins(registry) + + _assert_pairing(pairing, registry) + + +@pytest.mark.parametrize("pairing", DATA_PAIRINGS) +def test_data_pairings_reference_registered_slugs(pairing: dict[str, object]) -> None: + pytest.importorskip("datasets", reason="ergon-builtins[data] not installed") + from ergon_builtins.registry import register_builtins + + registry = ComponentRegistry() + register_builtins(registry) + + _assert_pairing(pairing, registry) + + +def _assert_pairing( + pairing: dict[str, object], + registry: ComponentRegistry, +) -> None: + benchmark = pairing["benchmark"] + worker = pairing["worker"] + evaluator = pairing["evaluator"] + sandbox = pairing["sandbox"] + extras = pairing["extras"] + + assert benchmark in registry.benchmarks + assert worker in registry.workers + assert evaluator in registry.evaluators + assert sandbox in registry.sandbox_managers + assert isinstance(extras, tuple) + assert extras diff --git a/ergon_core/tests/unit/registry/test_component_registry.py b/ergon_core/tests/unit/registry/test_component_registry.py new file mode 100644 index 00000000..14cc2f72 --- /dev/null +++ b/ergon_core/tests/unit/registry/test_component_registry.py @@ -0,0 +1,66 @@ +import pytest + +from ergon_core.api import Benchmark, Rubric, Worker +from ergon_core.api.registry import ComponentRegistry +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager + + +class ExampleWorker(Worker): + type_slug = "example-worker" + + +class ReplacementWorker(Worker): + type_slug = "example-worker" + + +class ExampleBenchmark(Benchmark): + type_slug = "example-benchmark" + + +class ExampleRubric(Rubric): + type_slug = "example-rubric" + + +class ExampleSandboxManager(BaseSandboxManager): + pass + + +def test_registers_components_by_explicit_or_type_slug() -> None: + registry = ComponentRegistry() + + registry.register_worker(ExampleWorker.type_slug, ExampleWorker) + registry.register_benchmark(ExampleBenchmark) + registry.register_evaluator(ExampleRubric) + registry.register_sandbox_manager("example-benchmark", ExampleSandboxManager) + + assert registry.require_worker("example-worker") is ExampleWorker + assert registry.require_benchmark("example-benchmark") is ExampleBenchmark + assert registry.require_evaluator("example-rubric") is ExampleRubric + assert registry.sandbox_managers["example-benchmark"] is ExampleSandboxManager + + +def test_duplicate_slug_rejects_different_object() -> None: + registry = ComponentRegistry() + registry.register_worker("example-worker", ExampleWorker) + + with pytest.raises(ValueError, match="Duplicate worker slug 'example-worker'"): + registry.register_worker("example-worker", ReplacementWorker) + + +def test_duplicate_slug_allows_idempotent_registration() -> None: + registry = ComponentRegistry() + registry.register_worker("example-worker", ExampleWorker) + registry.register_worker("example-worker", ExampleWorker) + + assert registry.require_worker("example-worker") is ExampleWorker + + +def test_unknown_slug_error_lists_registered_values() -> None: + registry = ComponentRegistry() + registry.register_worker("example-worker", ExampleWorker) + + with pytest.raises( + ValueError, + match="Unknown worker slug 'missing-worker'; registered workers: example-worker", + ): + registry.require_worker("missing-worker") diff --git a/ergon_core/tests/unit/registry/test_core_registry_boundary.py b/ergon_core/tests/unit/registry/test_core_registry_boundary.py new file mode 100644 index 00000000..7caf9c86 --- /dev/null +++ b/ergon_core/tests/unit/registry/test_core_registry_boundary.py @@ -0,0 +1,17 @@ +from pathlib import Path + + +def test_ergon_core_does_not_import_builtins_registry() -> None: + root = Path("ergon_core/ergon_core") + offenders: list[str] = [] + + for path in root.rglob("*.py"): + text = path.read_text() + if "ergon_builtins.registry" in text: + offenders.append(str(path)) + + assert offenders == [] + + +def test_core_package_has_no_smoke_fixture_registration_package() -> None: + assert not Path("ergon_core/ergon_core/test_support/smoke_fixtures").exists() diff --git a/ergon_core/tests/unit/registry/test_react_factories.py b/ergon_core/tests/unit/registry/test_react_factories.py new file mode 100644 index 00000000..f60595ae --- /dev/null +++ b/ergon_core/tests/unit/registry/test_react_factories.py @@ -0,0 +1,149 @@ +"""Smoke-test the new registry factory signatures.""" + +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from ergon_builtins.registry_core import WORKERS +from ergon_core.api import Worker + + +def test_registry_does_not_export_benchmark_profiles() -> None: + """Benchmark slugs should not imply worker/evaluator/sandbox defaults.""" + from ergon_builtins import registry + from ergon_builtins import registry_core + + assert not hasattr(registry_core, "BENCHMARK_PROFILES") + assert not hasattr(registry, "BENCHMARK_PROFILES") + + +def test_no_bare_react_v1_entry() -> None: + """RFC §1: `react-v1` bare entry removed — every factory binds a concrete toolkit.""" + assert "react-v1" not in WORKERS, ( + "Bare `react-v1` entry must not exist post-RFC. Use `minif2f-react` or " + "`swebench-react` instead." + ) + + +def test_shared_authoring_import_surfaces_exist() -> None: + """Generic built-in primitives should be available from ergon_builtins.shared.""" + from ergon_builtins.shared.criteria.code_check import CodeCheckCriterion + from ergon_builtins.shared.criteria.llm_judge import LLMJudgeCriterion + from ergon_builtins.shared.criteria.sandbox_file_check import SandboxFileCheckCriterion + from ergon_builtins.shared.models.resolution import resolve_model_target + from ergon_builtins.shared.workers.react_worker import ReActWorker + from ergon_builtins.shared.workers.training_stub_worker import TrainingStubWorker + + assert ReActWorker.type_slug == "react-v1" + assert TrainingStubWorker.type_slug == "training-stub" + assert CodeCheckCriterion.type_slug == "code-check" + assert LLMJudgeCriterion.type_slug == "llm-judge" + assert SandboxFileCheckCriterion.type_slug == "sandbox-file-check" + assert callable(resolve_model_target) + + +def test_training_stub_factory_accepts_new_kwargs(monkeypatch: pytest.MonkeyPatch) -> None: + """Non-benchmark factories must accept `task_id` / `sandbox_id` kwargs (option a).""" + factory = WORKERS["training-stub"] + worker = factory( + name="training-stub-under-test", + model=None, + task_id=uuid4(), + sandbox_id="sbx-abc", + ) + assert isinstance(worker, Worker) + assert worker.name == "training-stub-under-test" + + +def test_benchmark_react_factories_live_with_benchmarks() -> None: + """Benchmark-specific ReAct wiring should not live in the global registry module.""" + from ergon_builtins import registry_core + from ergon_builtins.benchmarks.minif2f.worker_factory import minif2f_react + from ergon_builtins.benchmarks.swebench_verified.worker_factory import swebench_react + from ergon_builtins.benchmarks.swebench_verified.rubric import SWEBenchRubric + from ergon_builtins.evaluators.rubrics.swebench_rubric import ( + SWEBenchRubric as LegacySWEBenchRubric, + ) + + assert registry_core.WORKERS["minif2f-react"] is minif2f_react + assert registry_core.WORKERS["swebench-react"] is swebench_react + assert registry_core.EVALUATORS["swebench-rubric"] is SWEBenchRubric + assert LegacySWEBenchRubric is SWEBenchRubric + + +def test_gdpeval_react_factory_lives_with_benchmark(monkeypatch: pytest.MonkeyPatch) -> None: + """GDPEval should expose a benchmark-owned ReAct factory through registry_data.""" + pytest.importorskip("datasets", reason="ergon-builtins[data] not installed") + from ergon_builtins.benchmarks.gdpeval import worker_factory + from ergon_builtins.registry_data import WORKERS as DATA_WORKERS + + assert DATA_WORKERS["gdpeval-react"] is worker_factory.gdpeval_react + + fake_toolkit = MagicMock() + fake_toolkit.get_tools.return_value = ["read_pdf", "run_python"] + monkeypatch.setattr(worker_factory, "GDPEvalToolkit", lambda **kwargs: fake_toolkit) + monkeypatch.setattr(worker_factory, "GDPEvalSandboxManager", lambda: MagicMock()) + + task_id = uuid4() + worker = DATA_WORKERS["gdpeval-react"]( + name="gdpeval-test", + model="openai:gpt-4o", + task_id=task_id, + sandbox_id="sbx-gdp", + ) + + assert isinstance(worker, Worker) + assert worker.tools == ["read_pdf", "run_python"] + assert worker.max_iterations == 40 + fake_toolkit.get_tools.assert_called_once_with() + + +def test_researchrubrics_workers_are_reexported_from_benchmark_factory() -> None: + """ResearchRubrics worker registry entries should come from the benchmark package.""" + pytest.importorskip("datasets", reason="ergon-builtins[data] not installed") + from ergon_builtins.benchmarks.researchrubrics.worker_factory import ( + ResearchRubricsResearcherWorker, + ResearchRubricsWorkflowCliReActWorker, + ) + from ergon_builtins.registry_data import WORKERS as DATA_WORKERS + + assert DATA_WORKERS["researchrubrics-researcher"] is ResearchRubricsResearcherWorker + assert ( + DATA_WORKERS["researchrubrics-workflow-cli-react"] + is ResearchRubricsWorkflowCliReActWorker + ) + + +def test_minif2f_factory_builds_toolkit(monkeypatch: pytest.MonkeyPatch) -> None: + """The minif2f factory must construct a live toolkit bound to the sandbox.""" + # reason: imports deferred to avoid pulling registry_core + sandbox_manager + # eagerly into test collection. Every test pulls its own patch target. + # reason: only needed for MagicMock spec= below; eager import would pull + # the benchmark sandbox module into all registry tests. + from ergon_builtins.benchmarks.minif2f import sandbox_manager as sm_mod + + from ergon_builtins.benchmarks.minif2f import worker_factory + + fake_sandbox = MagicMock(name="fake-sandbox") + fake_manager = MagicMock(spec=sm_mod.MiniF2FSandboxManager) + fake_manager.get_sandbox.return_value = fake_sandbox + # Patch on the call-site module so the test does not depend on lazy + # imports inside the factory. + monkeypatch.setattr(worker_factory, "MiniF2FSandboxManager", lambda: fake_manager) + + factory = WORKERS["minif2f-react"] + task_id = uuid4() + worker = factory( + name="minif2f-test", + model=None, + task_id=task_id, + sandbox_id="sbx-minif2f", + ) + assert isinstance(worker, Worker) + # Factory should have asked the manager for the sandbox + fake_manager.get_sandbox.assert_called_once_with(task_id) + # MiniF2FToolkit without ask_stakeholder_fn publishes exactly 4 tools: + # write_lean_file, check_lean_file, verify_lean_proof, search_lemmas + assert len(worker.tools) == 4 + # `max_iterations` must be explicit — 30 is the MiniF2F budget from the old adapter + assert worker.max_iterations == 30 diff --git a/ergon_core/tests/unit/registry/test_worker_spec_validation.py b/ergon_core/tests/unit/registry/test_worker_spec_validation.py new file mode 100644 index 00000000..3a988de2 --- /dev/null +++ b/ergon_core/tests/unit/registry/test_worker_spec_validation.py @@ -0,0 +1,21 @@ +import pytest + +from ergon_core.api.registry import registry +from ergon_core.core.domain.experiments import WorkerSpec + + +def test_worker_spec_unknown_worker_lists_registered_workers() -> None: + original_workers = dict(registry.workers) + registry.workers.clear() + registry.workers["known-worker"] = object + try: + spec = WorkerSpec(worker_slug="missing-worker", name="primary", model="stub:constant") + + with pytest.raises( + ValueError, + match="Unknown worker slug 'missing-worker'; registered workers: known-worker", + ): + spec.validate_spec() + finally: + registry.workers.clear() + registry.workers.update(original_workers) diff --git a/tests/unit/registry/__init__.py b/ergon_core/tests/unit/runtime/__init__.py similarity index 100% rename from tests/unit/registry/__init__.py rename to ergon_core/tests/unit/runtime/__init__.py diff --git a/tests/unit/runtime/test_child_function_payloads.py b/ergon_core/tests/unit/runtime/test_child_function_payloads.py similarity index 96% rename from tests/unit/runtime/test_child_function_payloads.py rename to ergon_core/tests/unit/runtime/test_child_function_payloads.py index 04cf2fb2..3566c7d6 100644 --- a/tests/unit/runtime/test_child_function_payloads.py +++ b/ergon_core/tests/unit/runtime/test_child_function_payloads.py @@ -3,7 +3,7 @@ from uuid import uuid4 import pytest -from ergon_core.core.runtime.services.child_function_payloads import ( +from ergon_core.core.infrastructure.inngest.contracts import ( EvaluateTaskRunRequest, PersistOutputsRequest, SandboxSetupRequest, diff --git a/tests/unit/runtime/test_cohort_rubric_status_summary.py b/ergon_core/tests/unit/runtime/test_cohort_rubric_status_summary.py similarity index 94% rename from tests/unit/runtime/test_cohort_rubric_status_summary.py rename to ergon_core/tests/unit/runtime/test_cohort_rubric_status_summary.py index 3e8d8bbe..16aa5107 100644 --- a/tests/unit/runtime/test_cohort_rubric_status_summary.py +++ b/ergon_core/tests/unit/runtime/test_cohort_rubric_status_summary.py @@ -1,10 +1,10 @@ """Cohort row rubric status summaries.""" from ergon_core.core.persistence.telemetry.evaluation_summary import ( - CriterionResultEntry, + CriterionOutcomeEntry, EvaluationSummary, ) -from ergon_core.core.runtime.services.cohort_service import _rubric_status_summary +from ergon_core.core.application.read_models.cohorts import _rubric_status_summary def _summary( @@ -18,7 +18,7 @@ def _summary( stages_evaluated=1, stages_passed=0, criterion_results=[ - CriterionResultEntry( + CriterionOutcomeEntry( criterion_name=f"{status}-criterion", criterion_type="test-criterion", stage_num=0, diff --git a/tests/unit/runtime/test_cohort_service.py b/ergon_core/tests/unit/runtime/test_cohort_service.py similarity index 96% rename from tests/unit/runtime/test_cohort_service.py rename to ergon_core/tests/unit/runtime/test_cohort_service.py index 27eb8508..95e602da 100644 --- a/tests/unit/runtime/test_cohort_service.py +++ b/ergon_core/tests/unit/runtime/test_cohort_service.py @@ -3,7 +3,7 @@ from ergon_core.core.persistence.shared.enums import RunStatus from ergon_core.core.persistence.telemetry.models import ExperimentRecord, RunRecord -from ergon_core.core.runtime.services.cohort_service import ExperimentCohortService +from ergon_core.core.application.read_models.cohorts import ExperimentCohortService def _experiment(status: str = "running") -> ExperimentRecord: diff --git a/tests/unit/runtime/test_communication_service.py b/ergon_core/tests/unit/runtime/test_communication_service.py similarity index 91% rename from tests/unit/runtime/test_communication_service.py rename to ergon_core/tests/unit/runtime/test_communication_service.py index 0dc1ac2f..4e76f3c6 100644 --- a/tests/unit/runtime/test_communication_service.py +++ b/ergon_core/tests/unit/runtime/test_communication_service.py @@ -2,10 +2,10 @@ from uuid import uuid4 import pytest -from ergon_core.core.dashboard.emitter import DashboardEmitter -from ergon_core.core.dashboard.provider import reset_dashboard_emitter, set_dashboard_emitter -from ergon_core.core.runtime.services import communication_service as module -from ergon_core.core.runtime.services.communication_schemas import CreateMessageRequest +from ergon_core.core.infrastructure.dashboard.emitter import DashboardEmitter +from ergon_core.core.infrastructure.dashboard.provider import reset_dashboard_emitter, set_dashboard_emitter +from ergon_core.core.application.communication import service as module +from ergon_core.core.application.communication.models import CreateMessageRequest from sqlalchemy.pool import StaticPool from sqlmodel import Session, SQLModel, create_engine, select diff --git a/tests/unit/runtime/test_context_event_contracts.py b/ergon_core/tests/unit/runtime/test_context_event_contracts.py similarity index 76% rename from tests/unit/runtime/test_context_event_contracts.py rename to ergon_core/tests/unit/runtime/test_context_event_contracts.py index 517068e7..14e97ad5 100644 --- a/tests/unit/runtime/test_context_event_contracts.py +++ b/ergon_core/tests/unit/runtime/test_context_event_contracts.py @@ -1,8 +1,8 @@ from uuid import uuid4 -from ergon_core.core.api.schemas import RunContextEventDto -from ergon_core.core.dashboard.event_contracts import DashboardContextEventEvent -from ergon_core.core.generation import AssistantTextPart, ContextPartChunkLog +from ergon_core.core.application.read_models.models import RunContextEventDto +from ergon_core.core.infrastructure.dashboard.event_contracts import DashboardContextEventEvent +from ergon_core.core.domain.generation.context_parts import AssistantTextPart, ContextPartChunkLog def test_rest_and_dashboard_context_events_share_typed_payload_shape() -> None: diff --git a/tests/unit/runtime/test_criterion_runtime_get_all_files.py b/ergon_core/tests/unit/runtime/test_criterion_runtime_get_all_files.py similarity index 92% rename from tests/unit/runtime/test_criterion_runtime_get_all_files.py rename to ergon_core/tests/unit/runtime/test_criterion_runtime_get_all_files.py index f77c9923..f8874612 100644 --- a/tests/unit/runtime/test_criterion_runtime_get_all_files.py +++ b/ergon_core/tests/unit/runtime/test_criterion_runtime_get_all_files.py @@ -15,11 +15,11 @@ from uuid import uuid4 import pytest -from ergon_core.core.runtime.evaluation.criterion_runtime import ( +from ergon_core.core.application.evaluation.criterion_runtime import ( CriterionRuntimeOptions, DefaultCriterionRuntime, ) -from ergon_core.core.runtime.evaluation.evaluation_schemas import CriterionContext +from ergon_core.core.application.evaluation.models import CriterionContext def _row(*, name: str, file_path: str, created_at: datetime) -> MagicMock: @@ -55,7 +55,7 @@ def _patch_session_with_rows(rows: list[MagicMock]): mock_session.__exit__ = MagicMock(return_value=False) mock_session.exec.return_value.all.return_value = rows return patch( - "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + "ergon_core.core.application.evaluation.criterion_runtime.get_session", return_value=mock_session, ) @@ -112,7 +112,7 @@ async def test_returns_empty_when_task_id_is_none() -> None: """Without a task_id, the helper returns ``{}`` and doesn't hit the DB.""" runtime = _make_runtime(run_id=uuid4(), task_id=None) - with patch("ergon_core.core.runtime.evaluation.criterion_runtime.get_session") as mock_get: + with patch("ergon_core.core.application.evaluation.criterion_runtime.get_session") as mock_get: result = await runtime.get_all_files_for_task() assert result == {} diff --git a/tests/unit/runtime/test_criterion_runtime_reconnect.py b/ergon_core/tests/unit/runtime/test_criterion_runtime_reconnect.py similarity index 95% rename from tests/unit/runtime/test_criterion_runtime_reconnect.py rename to ergon_core/tests/unit/runtime/test_criterion_runtime_reconnect.py index 8d068200..d1d3b7a6 100644 --- a/tests/unit/runtime/test_criterion_runtime_reconnect.py +++ b/ergon_core/tests/unit/runtime/test_criterion_runtime_reconnect.py @@ -12,12 +12,12 @@ from uuid import uuid4 import pytest -from ergon_core.core.sandbox.errors import SandboxExpiredError -from ergon_core.core.runtime.evaluation.criterion_runtime import ( +from ergon_core.core.infrastructure.sandbox.errors import SandboxExpiredError +from ergon_core.core.application.evaluation.criterion_runtime import ( CriterionRuntimeOptions, DefaultCriterionRuntime, ) -from ergon_core.core.runtime.evaluation.evaluation_schemas import CriterionContext +from ergon_core.core.application.evaluation.models import CriterionContext def _runtime(*, sandbox_id: str | None) -> DefaultCriterionRuntime: diff --git a/tests/unit/runtime/test_definition_lookup_boundaries.py b/ergon_core/tests/unit/runtime/test_definition_lookup_boundaries.py similarity index 60% rename from tests/unit/runtime/test_definition_lookup_boundaries.py rename to ergon_core/tests/unit/runtime/test_definition_lookup_boundaries.py index 82e658f4..537db375 100644 --- a/tests/unit/runtime/test_definition_lookup_boundaries.py +++ b/ergon_core/tests/unit/runtime/test_definition_lookup_boundaries.py @@ -1,10 +1,10 @@ -"""Runtime code should hydrate definition tasks through persistence helpers.""" +"""Application jobs should hydrate definition tasks through persistence helpers.""" from pathlib import Path -def test_inngest_runtime_does_not_query_definition_tables_directly() -> None: - runtime_dir = Path("ergon_core/ergon_core/core/runtime/inngest") +def test_inngest_jobs_do_not_query_definition_tables_directly() -> None: + runtime_dir = Path("ergon_core/ergon_core/core/application/jobs") forbidden = ( "ExperimentDefinitionTask", "ExperimentDefinitionInstance", diff --git a/tests/unit/runtime/test_definition_task_payload_typing.py b/ergon_core/tests/unit/runtime/test_definition_task_payload_typing.py similarity index 100% rename from tests/unit/runtime/test_definition_task_payload_typing.py rename to ergon_core/tests/unit/runtime/test_definition_task_payload_typing.py diff --git a/tests/unit/runtime/test_dynamic_task_evaluation_mapping.py b/ergon_core/tests/unit/runtime/test_dynamic_task_evaluation_mapping.py similarity index 95% rename from tests/unit/runtime/test_dynamic_task_evaluation_mapping.py rename to ergon_core/tests/unit/runtime/test_dynamic_task_evaluation_mapping.py index 2fe2e2c0..c5981c1f 100644 --- a/tests/unit/runtime/test_dynamic_task_evaluation_mapping.py +++ b/ergon_core/tests/unit/runtime/test_dynamic_task_evaluation_mapping.py @@ -1,9 +1,9 @@ from uuid import UUID, uuid4 import pytest -from ergon_core.core.api.runs import _task_keyed_evaluations from ergon_core.core.persistence.telemetry.models import RunTaskEvaluation from ergon_core.core.persistence.telemetry.repositories import CreateTaskEvaluation +from ergon_core.core.application.read_models.run_snapshot import _task_keyed_evaluations def _summary_json() -> dict: diff --git a/tests/unit/runtime/test_evaluation_context_schemas.py b/ergon_core/tests/unit/runtime/test_evaluation_context_schemas.py similarity index 90% rename from tests/unit/runtime/test_evaluation_context_schemas.py rename to ergon_core/tests/unit/runtime/test_evaluation_context_schemas.py index 5585e8ea..d41fdbb7 100644 --- a/tests/unit/runtime/test_evaluation_context_schemas.py +++ b/ergon_core/tests/unit/runtime/test_evaluation_context_schemas.py @@ -6,15 +6,15 @@ from ergon_core.core.persistence.definitions.models import ExperimentDefinitionTask from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.telemetry.models import RunTaskExecution -from ergon_core.core.runtime.evaluation.evaluation_schemas import ( +from ergon_core.core.application.evaluation.models import ( CriterionContext, TaskEvaluationContext, ) -from ergon_core.core.runtime.services.evaluation_dto import ( +from ergon_core.core.application.evaluation.models import ( DispatchEvaluatorsCommand, PreparedSingleEvaluator, ) -from ergon_core.core.runtime.services.evaluator_dispatch_service import EvaluatorDispatchService +from ergon_core.core.application.evaluation.service import EvaluationService from pydantic import ValidationError @@ -113,11 +113,11 @@ def close(self) -> None: pass monkeypatch.setattr( - "ergon_core.core.runtime.services.evaluator_dispatch_service.get_session", + "ergon_core.core.application.evaluation.service.get_session", _Session, ) - dispatch = EvaluatorDispatchService().prepare_dispatch( + dispatch = EvaluationService().prepare_dispatch( DispatchEvaluatorsCommand( run_id=uuid4(), definition_id=definition_id, diff --git a/ergon_core/tests/unit/runtime/test_evaluation_score_aggregation.py b/ergon_core/tests/unit/runtime/test_evaluation_score_aggregation.py new file mode 100644 index 00000000..8826c1fd --- /dev/null +++ b/ergon_core/tests/unit/runtime/test_evaluation_score_aggregation.py @@ -0,0 +1,25 @@ +from types import SimpleNamespace + +from ergon_core.core.application.evaluation.scoring import aggregate_evaluation_scores + + +def test_aggregate_evaluation_scores_counts_all_evaluators_and_averages_scored_rows() -> None: + summary = aggregate_evaluation_scores( + [ + SimpleNamespace(score=2.0), + SimpleNamespace(score=None), + SimpleNamespace(score=4.0), + ] + ) + + assert summary.final_score == 6.0 + assert summary.normalized_score == 3.0 + assert summary.evaluators_count == 3 + + +def test_aggregate_evaluation_scores_returns_none_scores_when_nothing_scored() -> None: + summary = aggregate_evaluation_scores([SimpleNamespace(score=None)]) + + assert summary.final_score is None + assert summary.normalized_score is None + assert summary.evaluators_count == 1 diff --git a/tests/unit/runtime/test_evaluation_summary_contracts.py b/ergon_core/tests/unit/runtime/test_evaluation_summary_contracts.py similarity index 90% rename from tests/unit/runtime/test_evaluation_summary_contracts.py rename to ergon_core/tests/unit/runtime/test_evaluation_summary_contracts.py index 87d6d92c..fd29ab27 100644 --- a/tests/unit/runtime/test_evaluation_summary_contracts.py +++ b/ergon_core/tests/unit/runtime/test_evaluation_summary_contracts.py @@ -6,28 +6,28 @@ import pytest from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import ( - CriterionObservation, - CriterionObservationMessage, - CriterionResult, - TaskEvaluationResult, +from ergon_core.api.criterion import ( + CriterionContext, + CriterionEvidence, + CriterionOutcome, + EvidenceMessage, ) -from ergon_core.core.persistence.telemetry.evaluation_summary import CriterionResultEntry -from ergon_core.core.runtime.evaluation.evaluation_schemas import CriterionSpec -from ergon_core.core.runtime.services.evaluation_persistence_service import ( +from ergon_core.api.rubric import TaskEvaluationResult +from ergon_core.core.persistence.telemetry.evaluation_summary import CriterionOutcomeEntry +from ergon_core.core.application.evaluation.models import CriterionSpec +from ergon_core.core.application.evaluation.service import ( build_dashboard_evaluation_dto, build_evaluation_summary, ) -from ergon_core.core.runtime.services.rubric_evaluation_service import EvaluationServiceResult +from ergon_core.core.application.evaluation.service import EvaluationServiceResult from pydantic import ValidationError class _Criterion(Criterion): type_slug = "test-criterion" - async def evaluate(self, context: EvaluationContext) -> CriterionResult: - return CriterionResult(name=self.slug, score=1.0, passed=True) + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: + return CriterionOutcome(name=self.slug, score=1.0, passed=True) def _service_result( @@ -44,7 +44,7 @@ def _service_result( evaluated_resource_ids: list[str] | None = None, criterion_evaluation_input: str | None = None, criterion_description: str = "Criterion description", - criterion_observation: CriterionObservation | None = None, + criterion_observation: CriterionEvidence | None = None, task_metadata: dict | None = None, ) -> EvaluationServiceResult: criterion = _Criterion( @@ -58,7 +58,7 @@ def _service_result( passed=passed, evaluator_name="rubric", criterion_results=[ - CriterionResult( + CriterionOutcome( name="criterion result", score=criterion_score, passed=passed, @@ -87,7 +87,7 @@ def _service_result( def test_criterion_result_entry_requires_criterion_description() -> None: with pytest.raises(ValidationError): - CriterionResultEntry( + CriterionOutcomeEntry( criterion_slug="criterion", criterion_name="criterion", criterion_type="test-criterion", @@ -97,7 +97,7 @@ def test_criterion_result_entry_requires_criterion_description() -> None: def test_criterion_result_entry_allows_nullable_optional_text_fields() -> None: - entry = CriterionResultEntry( + entry = CriterionOutcomeEntry( criterion_name="criterion", criterion_type="test-criterion", criterion_description="Criterion description", @@ -179,10 +179,10 @@ def test_build_evaluation_summary_uses_full_criterion_description_field() -> Non def test_build_evaluation_summary_preserves_structured_observation() -> None: - observation = CriterionObservation( + observation = CriterionEvidence( prompt_messages=[ - CriterionObservationMessage(role="system", content="Judge this rubric."), - CriterionObservationMessage(role="user", content="Evidence payload."), + EvidenceMessage(role="system", content="Judge this rubric."), + EvidenceMessage(role="user", content="Evidence payload."), ], evidence_resource_ids=["resource-1"], output={"passed": True, "reasoning": "sufficient"}, @@ -278,7 +278,7 @@ def test_build_evaluation_summary_reads_first_class_criterion_detail_fields() -> def test_summary_migration_normalizes_missing_criterion_fields() -> None: migration_path = ( - Path(__file__).parents[3] + Path(__file__).parents[4] / "ergon_core" / "migrations" / "versions" diff --git a/tests/unit/runtime/test_execute_task_readability.py b/ergon_core/tests/unit/runtime/test_execute_task_readability.py similarity index 87% rename from tests/unit/runtime/test_execute_task_readability.py rename to ergon_core/tests/unit/runtime/test_execute_task_readability.py index c2c56d10..48c463cd 100644 --- a/tests/unit/runtime/test_execute_task_readability.py +++ b/ergon_core/tests/unit/runtime/test_execute_task_readability.py @@ -2,7 +2,7 @@ import inspect -from ergon_core.core.runtime.inngest import execute_task +from ergon_core.core.application.jobs import execute_task def test_execute_task_module_exposes_named_phase_helpers() -> None: diff --git a/tests/unit/runtime/test_experiment_definition_service.py b/ergon_core/tests/unit/runtime/test_experiment_definition_service.py similarity index 75% rename from tests/unit/runtime/test_experiment_definition_service.py rename to ergon_core/tests/unit/runtime/test_experiment_definition_service.py index e5de6342..f1b78f48 100644 --- a/tests/unit/runtime/test_experiment_definition_service.py +++ b/ergon_core/tests/unit/runtime/test_experiment_definition_service.py @@ -1,13 +1,13 @@ from collections.abc import Mapping, Sequence from ergon_core.api.benchmark import Benchmark -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.benchmark import Task +from ergon_core.core.application.experiments import service as service_module +from ergon_core.core.application.experiments.models import ExperimentDefineRequest from ergon_core.core.persistence.telemetry.models import ExperimentRecord, RunRecord -from ergon_core.core.runtime.services import experiment_definition_service as service_module -from ergon_core.core.runtime.services.experiment_definition_service import ( - ExperimentDefinitionService, +from ergon_core.core.application.experiments.service import ( + ExperimentService, ) -from ergon_core.core.runtime.services.experiment_schemas import ExperimentDefineRequest from pydantic import BaseModel @@ -23,11 +23,11 @@ def __init__(self, *, limit: int | None = None) -> None: super().__init__() self.limit = limit - def build_instances(self) -> Mapping[str, Sequence[BenchmarkTask[BaseModel]]]: + def build_instances(self) -> Mapping[str, Sequence[Task[BaseModel]]]: selected = ["sample-a", "sample-b", "sample-c"][: self.limit] return { key: [ - BenchmarkTask( + Task( instance_key=key, task_slug=f"{key}-root", description=f"Task for {key}", @@ -61,7 +61,7 @@ def refresh(self, row) -> None: def test_define_benchmark_experiment_creates_experiment_record_without_runs(monkeypatch): session = _FakeSession() monkeypatch.setattr(service_module, "get_session", lambda: session) - service = ExperimentDefinitionService(benchmarks={"ci-benchmark": _Benchmark}) + service = ExperimentService(benchmarks={"ci-benchmark": _Benchmark}) result = service.define_benchmark_experiment( ExperimentDefineRequest( @@ -69,7 +69,9 @@ def test_define_benchmark_experiment_creates_experiment_record_without_runs(monk limit=2, default_model_target="openai:gpt-4o", default_worker_team={"primary": "test-worker"}, - default_evaluator_slug=None, + default_evaluator_slug="test-rubric", + sandbox_slug="test-sandbox", + dependency_extras=("none",), ) ) @@ -86,5 +88,7 @@ def test_define_benchmark_experiment_creates_experiment_record_without_runs(monk assert experiment.sample_selection_json == {"instance_keys": ["sample-a", "sample-b"]} assert experiment.default_worker_team_json == {"primary": "test-worker"} assert experiment.default_model_target == "openai:gpt-4o" - assert experiment.default_evaluator_slug is None + assert experiment.default_evaluator_slug == "test-rubric" + assert experiment.sandbox_slug == "test-sandbox" + assert experiment.dependency_extras_json == {"extras": ["none"]} assert experiment.status == "defined" diff --git a/tests/unit/runtime/test_experiment_launch_service.py b/ergon_core/tests/unit/runtime/test_experiment_launch_service.py similarity index 72% rename from tests/unit/runtime/test_experiment_launch_service.py rename to ergon_core/tests/unit/runtime/test_experiment_launch_service.py index e901d270..9ba7baeb 100644 --- a/tests/unit/runtime/test_experiment_launch_service.py +++ b/ergon_core/tests/unit/runtime/test_experiment_launch_service.py @@ -1,12 +1,12 @@ from uuid import uuid4 import pytest -from ergon_core.api.handles import PersistedExperimentDefinition +from ergon_core.core.application.experiments import launch as launch_module +from ergon_core.core.application.experiments.models import ExperimentRunRequest, RunAssignment +from ergon_core.core.domain.experiments import DefinitionHandle +from ergon_core.core.application.experiments.service import ExperimentService from ergon_core.core.persistence.shared.enums import RunStatus from ergon_core.core.persistence.telemetry.models import ExperimentRecord, RunRecord -from ergon_core.core.runtime.services import experiment_launch_service as service_module -from ergon_core.core.runtime.services.experiment_launch_service import ExperimentLaunchService -from ergon_core.core.runtime.services.experiment_schemas import ExperimentRunRequest, RunAssignment class _FakeSession: @@ -43,8 +43,10 @@ async def test_run_experiment_creates_one_run_per_selected_sample(monkeypatch): sample_count=2, sample_selection_json={"instance_keys": ["sample-a", "sample-b"]}, default_worker_team_json={"primary": "test-worker"}, - default_evaluator_slug=None, + default_evaluator_slug="test-rubric", default_model_target="openai:gpt-4o", + sandbox_slug="test-sandbox", + dependency_extras_json={"extras": ["none"]}, design_json={}, metadata_json={}, status="defined", @@ -55,8 +57,8 @@ async def test_run_experiment_creates_one_run_per_selected_sample(monkeypatch): def workflow_factory( experiment_record: ExperimentRecord, assignment: RunAssignment, - ) -> PersistedExperimentDefinition: - return PersistedExperimentDefinition( + ) -> DefinitionHandle: + return DefinitionHandle( definition_id=uuid4(), benchmark_type=experiment_record.benchmark_type, worker_bindings=assignment.worker_team, @@ -75,10 +77,10 @@ def fake_create_run(definition, **kwargs): async def fake_emit(run_id, definition_id): emitted.append((run_id, definition_id)) - monkeypatch.setattr(service_module, "get_session", lambda: _FakeSession(experiment)) - monkeypatch.setattr(service_module, "create_run", fake_create_run) + monkeypatch.setattr(launch_module, "get_session", lambda: _FakeSession(experiment)) + monkeypatch.setattr(launch_module, "create_run", fake_create_run) - service = ExperimentLaunchService( + service = ExperimentService( workflow_definition_factory=workflow_factory, emit_workflow_started=fake_emit, ) @@ -94,4 +96,10 @@ async def fake_emit(run_id, definition_id): {"primary": "test-worker"}, {"primary": "test-worker"}, ] + assert [run.evaluator_slug for run in created_runs] == ["test-rubric", "test-rubric"] + assert [run.sandbox_slug for run in created_runs] == ["test-sandbox", "test-sandbox"] + assert [run.dependency_extras_json for run in created_runs] == [ + {"extras": ["none"]}, + {"extras": ["none"]}, + ] assert len(emitted) == 2 diff --git a/tests/unit/runtime/test_experiment_read_service.py b/ergon_core/tests/unit/runtime/test_experiment_read_service.py similarity index 96% rename from tests/unit/runtime/test_experiment_read_service.py rename to ergon_core/tests/unit/runtime/test_experiment_read_service.py index 1ad15178..b26806cd 100644 --- a/tests/unit/runtime/test_experiment_read_service.py +++ b/ergon_core/tests/unit/runtime/test_experiment_read_service.py @@ -6,8 +6,8 @@ from ergon_core.core.persistence.graph.models import RunGraphNode from ergon_core.core.persistence.shared.enums import RunStatus from ergon_core.core.persistence.telemetry.models import ExperimentRecord, RunRecord -from ergon_core.core.runtime.services import experiment_read_service as module -from ergon_core.core.runtime.services.experiment_read_service import ExperimentReadService +from ergon_core.core.application.read_models import experiments as module +from ergon_core.core.application.read_models.experiments import ExperimentReadService from sqlalchemy.pool import StaticPool from sqlmodel import Session, SQLModel, create_engine diff --git a/tests/unit/runtime/test_experiment_schemas.py b/ergon_core/tests/unit/runtime/test_experiment_schemas.py similarity index 52% rename from tests/unit/runtime/test_experiment_schemas.py rename to ergon_core/tests/unit/runtime/test_experiment_schemas.py index 30aa4c9a..7c4275c0 100644 --- a/tests/unit/runtime/test_experiment_schemas.py +++ b/ergon_core/tests/unit/runtime/test_experiment_schemas.py @@ -1,7 +1,7 @@ from uuid import uuid4 import pytest -from ergon_core.core.runtime.services.experiment_schemas import ( +from ergon_core.core.application.experiments.models import ( ExperimentDefineRequest, ExperimentRunRequest, ) @@ -14,11 +14,16 @@ def test_define_request_accepts_optional_name_cohort_and_evaluator() -> None: limit=5, default_model_target="anthropic:claude-sonnet-4.6", default_worker_team={"primary": "researchrubrics-workflow-cli-react"}, + default_evaluator_slug="researchrubrics-rubric", + sandbox_slug="researchrubrics", + dependency_extras=("ergon-builtins[data]",), ) assert request.name is None assert request.cohort_id is None - assert request.default_evaluator_slug is None + assert request.default_evaluator_slug == "researchrubrics-rubric" + assert request.sandbox_slug == "researchrubrics" + assert request.dependency_extras == ("ergon-builtins[data]",) @pytest.mark.parametrize( @@ -28,6 +33,9 @@ def test_define_request_accepts_optional_name_cohort_and_evaluator() -> None: "benchmark_slug": "researchrubrics", "default_model_target": "anthropic:claude-sonnet-4.6", "default_worker_team": {"primary": "worker"}, + "default_evaluator_slug": "rubric", + "sandbox_slug": "sandbox", + "dependency_extras": ("none",), }, { "benchmark_slug": "researchrubrics", @@ -35,6 +43,9 @@ def test_define_request_accepts_optional_name_cohort_and_evaluator() -> None: "sample_ids": ["a"], "default_model_target": "anthropic:claude-sonnet-4.6", "default_worker_team": {"primary": "worker"}, + "default_evaluator_slug": "rubric", + "sandbox_slug": "sandbox", + "dependency_extras": ("none",), }, ], ) @@ -48,6 +59,42 @@ def test_define_request_requires_assignment_defaults_without_arms() -> None: ExperimentDefineRequest( benchmark_slug="researchrubrics", limit=5, + default_evaluator_slug="rubric", + sandbox_slug="sandbox", + dependency_extras=("none",), + ) + + +def test_define_request_requires_explicit_evaluator_sandbox_and_extras() -> None: + with pytest.raises(ValidationError, match="default_evaluator_slug"): + ExperimentDefineRequest( + benchmark_slug="researchrubrics", + limit=5, + default_model_target="anthropic:claude-sonnet-4.6", + default_worker_team={"primary": "worker"}, + sandbox_slug="sandbox", + dependency_extras=("none",), + ) + + with pytest.raises(ValidationError, match="sandbox_slug"): + ExperimentDefineRequest( + benchmark_slug="researchrubrics", + limit=5, + default_model_target="anthropic:claude-sonnet-4.6", + default_worker_team={"primary": "worker"}, + default_evaluator_slug="rubric", + dependency_extras=("none",), + ) + + with pytest.raises(ValidationError, match="dependency_extras"): + ExperimentDefineRequest( + benchmark_slug="researchrubrics", + limit=5, + default_model_target="anthropic:claude-sonnet-4.6", + default_worker_team={"primary": "worker"}, + default_evaluator_slug="rubric", + sandbox_slug="sandbox", + dependency_extras=(), ) @@ -58,6 +105,9 @@ def test_define_request_rejects_design_arms_until_launch_support_exists() -> Non sample_ids=["a"], default_model_target="anthropic:claude-sonnet-4.6", default_worker_team={"primary": "worker"}, + default_evaluator_slug="rubric", + sandbox_slug="sandbox", + dependency_extras=("none",), design={ "arms": { "baseline": { diff --git a/tests/unit/runtime/test_failed_task_sandbox_cleanup.py b/ergon_core/tests/unit/runtime/test_failed_task_sandbox_cleanup.py similarity index 70% rename from tests/unit/runtime/test_failed_task_sandbox_cleanup.py rename to ergon_core/tests/unit/runtime/test_failed_task_sandbox_cleanup.py index 6b226102..94eeed71 100644 --- a/tests/unit/runtime/test_failed_task_sandbox_cleanup.py +++ b/ergon_core/tests/unit/runtime/test_failed_task_sandbox_cleanup.py @@ -1,11 +1,11 @@ from unittest.mock import AsyncMock, patch import pytest -from ergon_core.core.sandbox.lifecycle import ( +from ergon_core.core.infrastructure.sandbox.lifecycle import ( SandboxTerminationReason, SandboxTerminationResult, ) -from ergon_core.core.runtime.inngest.propagate_execution import _terminate_failed_task_sandbox +from ergon_core.core.application.jobs.propagate_execution import _terminate_failed_task_sandbox @pytest.mark.asyncio @@ -16,7 +16,7 @@ async def test_failed_task_sandbox_cleanup_delegates_to_lifecycle_service() -> N reason=SandboxTerminationReason.TERMINATED, ) with patch( - "ergon_core.core.runtime.inngest.propagate_execution.terminate_sandbox_by_id", + "ergon_core.core.application.jobs.propagate_execution.terminate_sandbox_by_id", new=AsyncMock(return_value=result), ) as terminate: await _terminate_failed_task_sandbox("sandbox-real") diff --git a/tests/unit/runtime/test_failure_error_json.py b/ergon_core/tests/unit/runtime/test_failure_error_json.py similarity index 86% rename from tests/unit/runtime/test_failure_error_json.py rename to ergon_core/tests/unit/runtime/test_failure_error_json.py index 2b4aea1c..19d2612c 100644 --- a/tests/unit/runtime/test_failure_error_json.py +++ b/ergon_core/tests/unit/runtime/test_failure_error_json.py @@ -3,13 +3,13 @@ from uuid import uuid4 import pytest -from ergon_core.core.runtime.services.orchestration_dto import FailTaskExecutionCommand +from ergon_core.core.application.workflows.orchestration import FailTaskExecutionCommand @pytest.mark.asyncio async def test_finalize_failure_preserves_structured_error_json(monkeypatch) -> None: - from ergon_core.core.runtime.services import task_execution_service as module - from ergon_core.core.runtime.services.task_execution_service import TaskExecutionService + from ergon_core.core.application.tasks import execution as module + from ergon_core.core.application.tasks.execution import TaskExecutionService execution_id = uuid4() run_id = uuid4() diff --git a/tests/unit/runtime/test_graph_mutation_contracts.py b/ergon_core/tests/unit/runtime/test_graph_mutation_contracts.py similarity index 87% rename from tests/unit/runtime/test_graph_mutation_contracts.py rename to ergon_core/tests/unit/runtime/test_graph_mutation_contracts.py index 276f1dab..14d209a7 100644 --- a/tests/unit/runtime/test_graph_mutation_contracts.py +++ b/ergon_core/tests/unit/runtime/test_graph_mutation_contracts.py @@ -1,7 +1,7 @@ from uuid import uuid4 -from ergon_core.core.dashboard.event_contracts import DashboardGraphMutationEvent -from ergon_core.core.runtime.services.graph_dto import ( +from ergon_core.core.infrastructure.dashboard.event_contracts import DashboardGraphMutationEvent +from ergon_core.core.application.graph.models import ( EdgeAddedMutation, GraphMutationRecordDto, GraphMutationValue, diff --git a/ergon_core/tests/unit/runtime/test_graph_traversal.py b/ergon_core/tests/unit/runtime/test_graph_traversal.py new file mode 100644 index 00000000..596a0726 --- /dev/null +++ b/ergon_core/tests/unit/runtime/test_graph_traversal.py @@ -0,0 +1,68 @@ +from uuid import UUID, uuid4 + +from ergon_core.core.persistence.graph.models import RunGraphNode +from ergon_core.core.application.graph.traversal import descendant_ids, descendants +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + + +def _session() -> Session: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def _node( + session: Session, + *, + run_id: UUID, + slug: str, + parent_node_id: UUID | None = None, + status: str = "pending", +) -> RunGraphNode: + node = RunGraphNode( + run_id=run_id, + instance_key="sample-1", + task_slug=slug, + description=f"Task {slug}", + status=status, + parent_node_id=parent_node_id, + ) + session.add(node) + session.flush() + return node + + +def test_descendants_walks_full_containment_subtree_past_terminal_nodes() -> None: + session = _session() + run_id = uuid4() + root = _node(session, run_id=run_id, slug="root") + child = _node(session, run_id=run_id, slug="child", parent_node_id=root.id, status="completed") + grandchild = _node(session, run_id=run_id, slug="grandchild", parent_node_id=child.id) + sibling = _node(session, run_id=run_id, slug="sibling", parent_node_id=root.id) + other_run_child = _node(session, run_id=uuid4(), slug="other", parent_node_id=root.id) + session.commit() + + walked = descendants(session, run_id=run_id, root_node_id=root.id) + + assert [node.id for node in walked] == [child.id, sibling.id, grandchild.id] + assert other_run_child.id not in {node.id for node in walked} + + +def test_descendant_ids_respects_max_depth() -> None: + session = _session() + run_id = uuid4() + root = _node(session, run_id=run_id, slug="root") + child = _node(session, run_id=run_id, slug="child", parent_node_id=root.id) + grandchild = _node(session, run_id=run_id, slug="grandchild", parent_node_id=child.id) + session.commit() + + assert descendant_ids(session, run_id=run_id, root_node_id=root.id, max_depth=1) == {child.id} + assert descendant_ids(session, run_id=run_id, root_node_id=root.id, max_depth=2) == { + child.id, + grandchild.id, + } diff --git a/tests/unit/runtime/test_graph_worker_identity.py b/ergon_core/tests/unit/runtime/test_graph_worker_identity.py similarity index 89% rename from tests/unit/runtime/test_graph_worker_identity.py rename to ergon_core/tests/unit/runtime/test_graph_worker_identity.py index e4564d3a..ca2f78f3 100644 --- a/tests/unit/runtime/test_graph_worker_identity.py +++ b/ergon_core/tests/unit/runtime/test_graph_worker_identity.py @@ -15,19 +15,17 @@ RunRecord, RunTaskExecution, ) -from ergon_core.core.runtime.services import task_execution_service as task_execution_module -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.orchestration_dto import ( +from ergon_core.core.application.tasks import execution as task_execution_module +from ergon_core.core.application.graph.models import MutationMeta +from ergon_core.core.application.graph.repository import WorkflowGraphRepository +from ergon_core.core.application.workflows.orchestration import ( InitializeWorkflowCommand, PrepareTaskExecutionCommand, ) -from ergon_core.core.runtime.services.task_management_dto import AddSubtaskCommand -from ergon_core.core.runtime.services.task_management_service import TaskManagementService -from ergon_core.core.runtime.services.task_execution_service import TaskExecutionService -from ergon_core.core.runtime.services.workflow_initialization_service import ( - WorkflowInitializationService, -) +from ergon_core.core.application.tasks.models import AddSubtaskCommand +from ergon_core.core.application.tasks.management import TaskManagementService +from ergon_core.core.application.tasks.execution import TaskExecutionService +from ergon_core.core.application.workflows.service import WorkflowService from pydantic import BaseModel from sqlalchemy.pool import StaticPool from sqlmodel import Session, SQLModel, create_engine, select @@ -169,20 +167,19 @@ async def test_workflow_initialization_returns_node_ids_for_initial_ready_static class _Benchmark: task_payload_model = _Payload + from ergon_core.api.registry import registry + monkeypatch.setitem( - __import__( - "ergon_core.core.runtime.services.workflow_initialization_service", - fromlist=["BENCHMARKS"], - ).BENCHMARKS, + registry.benchmarks, benchmark_type, _Benchmark, ) monkeypatch.setattr( - "ergon_core.core.runtime.services.workflow_initialization_service.get_session", + "ergon_core.core.application.workflows.service.get_session", lambda: _session_context(session), ) - initialized = await WorkflowInitializationService().initialize( + initialized = await WorkflowService().initialize( InitializeWorkflowCommand(run_id=run_id, definition_id=definition_id) ) diff --git a/tests/unit/runtime/test_import_boundaries.py b/ergon_core/tests/unit/runtime/test_import_boundaries.py similarity index 54% rename from tests/unit/runtime/test_import_boundaries.py rename to ergon_core/tests/unit/runtime/test_import_boundaries.py index c3ad1719..58fb6ce0 100644 --- a/tests/unit/runtime/test_import_boundaries.py +++ b/ergon_core/tests/unit/runtime/test_import_boundaries.py @@ -1,7 +1,7 @@ def test_telemetry_models_import_before_run_resource_api() -> None: from ergon_core.core.persistence.telemetry.models import RunResource - from ergon_core.core.runtime.resources import RunResourceView + from ergon_core.core.application.resources import RunResourceView assert RunResource.__tablename__ == "run_resources" assert RunResourceView.__name__ == "RunResourceView" @@ -14,7 +14,7 @@ def test_context_models_import_without_worker_cycle() -> None: def test_context_event_payloads_use_shared_logprob_type_without_api_cycle() -> None: - from ergon_core.core.generation import ContextPartChunkLog, TokenLogprob + from ergon_core.core.domain.generation.context_parts import ContextPartChunkLog, TokenLogprob from ergon_core.core.persistence.context.event_payloads import ContextEventPayload assert ContextEventPayload is ContextPartChunkLog @@ -22,7 +22,20 @@ def test_context_event_payloads_use_shared_logprob_type_without_api_cycle() -> N def test_worker_execute_does_not_expose_result_adapter_helpers() -> None: - import ergon_core.core.runtime.inngest.worker_execute as worker_execute + import ergon_core.core.application.jobs.worker_execute as worker_execute assert not hasattr(worker_execute, "_worker_execute_result_from_output") assert not hasattr(worker_execute, "_worker_execute_result_from_exception") + + +def test_runs_api_does_not_own_run_snapshot_read_model_helpers() -> None: + import ergon_core.core.rest_api.runs as runs_api + + assert not hasattr(runs_api, "_build_task_map") + assert not hasattr(runs_api, "_task_keyed_executions") + assert not hasattr(runs_api, "_task_keyed_resources") + assert not hasattr(runs_api, "_task_keyed_evaluations") + assert not hasattr(runs_api, "_task_keyed_sandboxes") + assert not hasattr(runs_api, "_build_communication_threads") + assert not hasattr(runs_api, "_task_timestamps") + assert not hasattr(runs_api, "_context_events_by_task") diff --git a/tests/unit/runtime/test_inngest_criterion_executor.py b/ergon_core/tests/unit/runtime/test_inngest_criterion_executor.py similarity index 69% rename from tests/unit/runtime/test_inngest_criterion_executor.py rename to ergon_core/tests/unit/runtime/test_inngest_criterion_executor.py index 9a9e257d..702a10b5 100644 --- a/tests/unit/runtime/test_inngest_criterion_executor.py +++ b/ergon_core/tests/unit/runtime/test_inngest_criterion_executor.py @@ -4,14 +4,14 @@ import pytest from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.evaluation.evaluation_schemas import ( +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.criterion import CriterionOutcome +from ergon_core.api.benchmark import Task +from ergon_core.core.application.evaluation.models import ( CriterionSpec, TaskEvaluationContext, ) -from ergon_core.core.runtime.evaluation.inngest_executor import InngestCriterionExecutor +from ergon_core.core.application.evaluation.inngest_executor import InngestCriterionExecutor class _Step: @@ -34,11 +34,11 @@ class _Criterion(Criterion): def __init__(self) -> None: super().__init__(slug="criterion") - self.runtime_task_scope = None + self.observed_runtime = False - async def evaluate(self, context: EvaluationContext) -> CriterionResult: - self.runtime_task_scope = context.runtime.task_scope - return CriterionResult(name=self.slug, score=1.0, passed=True) + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: + self.observed_runtime = context.has_runtime + return CriterionOutcome(name=self.slug, score=1.0, passed=True) @pytest.mark.asyncio @@ -53,7 +53,7 @@ def __init__(self, *, context, sandbox_manager, options) -> None: self.task_scope = options.task_id monkeypatch.setattr( - "ergon_core.core.runtime.evaluation.inngest_executor.DefaultCriterionRuntime", + "ergon_core.core.application.evaluation.inngest_executor.DefaultCriterionRuntime", FakeRuntime, ) @@ -72,7 +72,7 @@ def __init__(self, *, context, sandbox_manager, options) -> None: task_input="input", agent_reasoning="output", ), - BenchmarkTask( + Task( task_slug="task", instance_key="default", description="input", @@ -83,4 +83,4 @@ def __init__(self, *, context, sandbox_manager, options) -> None: ) assert captured_options[0].task_id == execution_id - assert criterion.runtime_task_scope == execution_id + assert criterion.observed_runtime is True diff --git a/tests/unit/runtime/test_inngest_package_layout.py b/ergon_core/tests/unit/runtime/test_inngest_package_layout.py similarity index 51% rename from tests/unit/runtime/test_inngest_package_layout.py rename to ergon_core/tests/unit/runtime/test_inngest_package_layout.py index 6d2e9f88..10122748 100644 --- a/tests/unit/runtime/test_inngest_package_layout.py +++ b/ergon_core/tests/unit/runtime/test_inngest_package_layout.py @@ -3,8 +3,8 @@ def test_inngest_infrastructure_lives_in_inngest_package() -> None: - client_module = importlib.import_module("ergon_core.core.runtime.inngest.client") - registry_spec = importlib.util.find_spec("ergon_core.core.runtime.inngest.registry") + client_module = importlib.import_module("ergon_core.core.infrastructure.inngest.client") + registry_spec = importlib.util.find_spec("ergon_core.core.infrastructure.inngest.registry") assert client_module.inngest_client is not None assert registry_spec is not None diff --git a/tests/unit/runtime/test_persist_outputs_resources.py b/ergon_core/tests/unit/runtime/test_persist_outputs_resources.py similarity index 87% rename from tests/unit/runtime/test_persist_outputs_resources.py rename to ergon_core/tests/unit/runtime/test_persist_outputs_resources.py index dc3b97b3..94b12819 100644 --- a/tests/unit/runtime/test_persist_outputs_resources.py +++ b/ergon_core/tests/unit/runtime/test_persist_outputs_resources.py @@ -1,8 +1,8 @@ from uuid import uuid4 import pytest -from ergon_core.core.runtime.inngest import persist_outputs -from ergon_core.core.runtime.services.child_function_payloads import PersistOutputsRequest +from ergon_core.core.application.jobs import persist_outputs +from ergon_core.core.infrastructure.inngest.contracts import PersistOutputsRequest class _Manager: diff --git a/tests/unit/runtime/test_propagation_contracts.py b/ergon_core/tests/unit/runtime/test_propagation_contracts.py similarity index 69% rename from tests/unit/runtime/test_propagation_contracts.py rename to ergon_core/tests/unit/runtime/test_propagation_contracts.py index a444da09..7101a2bb 100644 --- a/tests/unit/runtime/test_propagation_contracts.py +++ b/ergon_core/tests/unit/runtime/test_propagation_contracts.py @@ -1,8 +1,8 @@ from ergon_core.core.persistence.graph import status_conventions as graph_status -from ergon_core.core.runtime.execution import propagation -from ergon_core.core.runtime.services import task_execution_service, task_propagation_service -from ergon_core.core.runtime.services import workflow_initialization_service -from ergon_core.core.runtime.services.orchestration_dto import PropagationResult +from ergon_core.core.application.tasks import execution as task_execution_service +from ergon_core.core.application.workflows import service as workflow_service +from ergon_core.core.application.workflows.orchestration import PropagationResult +from ergon_core.core.application.graph import propagation as workflow_propagation_service def _source(module: object) -> str: @@ -14,10 +14,9 @@ def _source(module: object) -> str: def test_graph_writers_do_not_use_task_execution_status_for_node_status() -> None: modules = [ - propagation, task_execution_service, - task_propagation_service, - workflow_initialization_service, + workflow_service, + workflow_propagation_service, ] forbidden_snippets = ( "new_status=TaskExecutionStatus.", diff --git a/tests/unit/runtime/test_real_llm_rollout_artifact_health.py b/ergon_core/tests/unit/runtime/test_real_llm_rollout_artifact_health.py similarity index 100% rename from tests/unit/runtime/test_real_llm_rollout_artifact_health.py rename to ergon_core/tests/unit/runtime/test_real_llm_rollout_artifact_health.py diff --git a/tests/unit/runtime/test_rubric_evaluation_service.py b/ergon_core/tests/unit/runtime/test_rubric_evaluation_service.py similarity index 68% rename from tests/unit/runtime/test_rubric_evaluation_service.py rename to ergon_core/tests/unit/runtime/test_rubric_evaluation_service.py index 0d499912..67ba3075 100644 --- a/tests/unit/runtime/test_rubric_evaluation_service.py +++ b/ergon_core/tests/unit/runtime/test_rubric_evaluation_service.py @@ -4,16 +4,16 @@ from uuid import uuid4 from ergon_core.api.criterion import Criterion -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.evaluator import Rubric -from ergon_core.api.results import CriterionResult, CriterionScoreSpec -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.evaluation.evaluation_schemas import ( +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.rubric import Rubric +from ergon_core.api.criterion import CriterionOutcome, ScoreScale +from ergon_core.api.benchmark import Task +from ergon_core.core.application.evaluation.models import ( CriterionSpec, TaskEvaluationContext, ) -from ergon_core.core.runtime.services.rubric_evaluation_service import ( - RubricEvaluationService, +from ergon_core.core.application.evaluation.service import ( + EvaluationService, ) @@ -24,11 +24,11 @@ def __init__(self, *, slug: str, weight: float, max_score: float) -> None: super().__init__( slug=slug, weight=weight, - score_spec=CriterionScoreSpec(max_score=max_score), + score_spec=ScoreScale(max_score=max_score), ) - async def evaluate(self, context: EvaluationContext) -> CriterionResult: - return CriterionResult(name=self.slug, score=self.score_spec.max_score, passed=True) + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: + return CriterionOutcome(name=self.slug, score=self.score_spec.max_score, passed=True) class _Executor: @@ -38,13 +38,13 @@ def __init__(self) -> None: async def execute_all( self, task_context: TaskEvaluationContext, - task: BenchmarkTask, + task: Task, benchmark_name: str, criteria: list[CriterionSpec], - ) -> list[CriterionResult]: + ) -> list[CriterionOutcome]: self.seen_specs = criteria return [ - CriterionResult( + CriterionOutcome( name=spec.criterion.slug, score=spec.max_score, passed=True, @@ -57,7 +57,7 @@ async def execute_all( @pytest.mark.asyncio async def test_rubric_service_uses_criterion_max_score_not_signed_weight() -> None: executor = _Executor() - service = RubricEvaluationService(executor) + service = EvaluationService(executor) evaluator = Rubric( name="rubric", criteria=[ @@ -73,7 +73,7 @@ async def test_rubric_service_uses_criterion_max_score_not_signed_weight() -> No agent_reasoning=None, ), evaluator, - BenchmarkTask( + Task( task_slug="task", instance_key="default", description="Task", diff --git a/tests/unit/runtime/test_run_record_missing_error.py b/ergon_core/tests/unit/runtime/test_run_record_missing_error.py similarity index 85% rename from tests/unit/runtime/test_run_record_missing_error.py rename to ergon_core/tests/unit/runtime/test_run_record_missing_error.py index fe2e5516..a91bbe51 100644 --- a/tests/unit/runtime/test_run_record_missing_error.py +++ b/ergon_core/tests/unit/runtime/test_run_record_missing_error.py @@ -4,8 +4,8 @@ from uuid import uuid4 import pytest -from ergon_core.core.runtime.errors.delegation_errors import RunRecordMissingError -from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from ergon_core.core.application.tasks.errors import RunRecordMissingError +from ergon_core.core.application.tasks.management import TaskManagementService def test_error_message_contains_run_id(): diff --git a/tests/unit/runtime/test_run_service.py b/ergon_core/tests/unit/runtime/test_run_service.py similarity index 91% rename from tests/unit/runtime/test_run_service.py rename to ergon_core/tests/unit/runtime/test_run_service.py index 67e6b941..575d6dee 100644 --- a/tests/unit/runtime/test_run_service.py +++ b/ergon_core/tests/unit/runtime/test_run_service.py @@ -1,8 +1,8 @@ from uuid import uuid4 -from ergon_core.api.handles import PersistedExperimentDefinition +from ergon_core.core.domain.experiments import DefinitionHandle from ergon_core.core.persistence.shared.enums import RunStatus -from ergon_core.core.runtime.services import run_service +from ergon_core.core.application.workflows import runs as run_service class _FakeSession: @@ -29,7 +29,7 @@ def test_create_run_requires_experiment_identity_and_records_workflow_assignment session = _FakeSession() experiment_id = uuid4() workflow_definition_id = uuid4() - definition = PersistedExperimentDefinition( + definition = DefinitionHandle( definition_id=workflow_definition_id, benchmark_type="ci-benchmark", worker_bindings={"primary": "test-worker"}, diff --git a/ergon_core/tests/unit/runtime/test_sandbox_setup_explicit_slug.py b/ergon_core/tests/unit/runtime/test_sandbox_setup_explicit_slug.py new file mode 100644 index 00000000..43b0c8b6 --- /dev/null +++ b/ergon_core/tests/unit/runtime/test_sandbox_setup_explicit_slug.py @@ -0,0 +1,29 @@ +"""Sandbox setup honors explicit sandbox slugs.""" + +from uuid import uuid4 + +from ergon_core.core.application.jobs.sandbox_setup import _sandbox_manager_slug +from ergon_core.core.infrastructure.inngest.contracts import SandboxSetupRequest + + +def test_sandbox_setup_prefers_explicit_sandbox_slug() -> None: + payload = SandboxSetupRequest( + run_id=uuid4(), + definition_id=uuid4(), + task_id=uuid4(), + benchmark_type="benchmark-slug", + sandbox_slug="sandbox-slug", + ) + + assert _sandbox_manager_slug(payload) == "sandbox-slug" + + +def test_sandbox_setup_falls_back_to_benchmark_type() -> None: + payload = SandboxSetupRequest( + run_id=uuid4(), + definition_id=uuid4(), + task_id=uuid4(), + benchmark_type="benchmark-slug", + ) + + assert _sandbox_manager_slug(payload) == "benchmark-slug" diff --git a/tests/unit/runtime/test_smoke_topology_drift.py b/ergon_core/tests/unit/runtime/test_smoke_topology_drift.py similarity index 88% rename from tests/unit/runtime/test_smoke_topology_drift.py rename to ergon_core/tests/unit/runtime/test_smoke_topology_drift.py index 727859af..38f714bb 100644 --- a/tests/unit/runtime/test_smoke_topology_drift.py +++ b/ergon_core/tests/unit/runtime/test_smoke_topology_drift.py @@ -6,10 +6,10 @@ import re from pathlib import Path -from ergon_core.test_support.smoke_fixtures.smoke_base.constants import ( +from tests.fixtures.smoke_components.smoke_base.constants import ( EXPECTED_SUBTASK_SLUGS, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import NESTED_LINE_SLUGS +from tests.fixtures.smoke_components.smoke_base.recursive import NESTED_LINE_SLUGS def test_playwright_expected_subtask_slugs_match_python_smoke_topology() -> None: diff --git a/ergon_core/tests/unit/runtime/test_task_execution_repository.py b/ergon_core/tests/unit/runtime/test_task_execution_repository.py new file mode 100644 index 00000000..18448dc1 --- /dev/null +++ b/ergon_core/tests/unit/runtime/test_task_execution_repository.py @@ -0,0 +1,155 @@ +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +from ergon_core.core.persistence.graph.models import RunGraphNode +from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus +from ergon_core.core.persistence.telemetry.models import RunRecord, RunTaskExecution +from ergon_core.core.application.tasks.repository import TaskExecutionRepository +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + + +def _session() -> Session: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def _run(session: Session) -> UUID: + run_id = uuid4() + session.add( + RunRecord( + id=run_id, + experiment_id=uuid4(), + workflow_definition_id=uuid4(), + benchmark_type="ci-task-execution-repository", + instance_key="sample-1", + worker_team_json={"primary": "test-worker"}, + status=RunStatus.EXECUTING, + ) + ) + return run_id + + +def _node(session: Session, run_id: UUID) -> UUID: + node = RunGraphNode( + run_id=run_id, + instance_key="sample-1", + task_slug="task", + description="Task", + status="running", + ) + session.add(node) + session.flush() + return node.id + + +def _execution( + *, + run_id: UUID, + node_id: UUID, + attempt_number: int, + started_at: datetime, + definition_task_id: UUID | None = None, + message: str = "output", +) -> RunTaskExecution: + return RunTaskExecution( + run_id=run_id, + node_id=node_id, + definition_task_id=definition_task_id, + attempt_number=attempt_number, + status=TaskExecutionStatus.COMPLETED, + started_at=started_at, + final_assistant_message=message, + ) + + +def test_latest_for_node_orders_by_attempt_then_started_at() -> None: + session = _session() + run_id = _run(session) + node_id = _node(session, run_id) + now = datetime(2026, 4, 28, 12, 0, tzinfo=UTC) + older_attempt_two = _execution( + run_id=run_id, + node_id=node_id, + attempt_number=2, + started_at=now, + message="attempt-two-old", + ) + newer_attempt_one = _execution( + run_id=run_id, + node_id=node_id, + attempt_number=1, + started_at=now + timedelta(minutes=10), + message="attempt-one-newer", + ) + newer_attempt_two = _execution( + run_id=run_id, + node_id=node_id, + attempt_number=2, + started_at=now + timedelta(minutes=5), + message="attempt-two-new", + ) + session.add_all([older_attempt_two, newer_attempt_one, newer_attempt_two]) + session.commit() + + latest = TaskExecutionRepository().latest_for_node(session, node_id) + + assert latest is not None + assert latest.id == newer_attempt_two.id + assert latest.id != newer_attempt_one.id + + +def test_next_attempt_counts_existing_node_executions() -> None: + session = _session() + run_id = _run(session) + node_id = _node(session, run_id) + now = datetime(2026, 4, 28, 12, 0, tzinfo=UTC) + session.add_all( + [ + _execution(run_id=run_id, node_id=node_id, attempt_number=1, started_at=now), + _execution(run_id=run_id, node_id=node_id, attempt_number=2, started_at=now), + ] + ) + session.commit() + + assert TaskExecutionRepository().next_attempt_for_node(session, run_id, node_id) == 3 + + +def test_latest_for_definition_task_uses_same_ordering_as_node_lookup() -> None: + session = _session() + run_id = _run(session) + node_id = _node(session, run_id) + definition_task_id = uuid4() + now = datetime(2026, 4, 28, 12, 0, tzinfo=UTC) + older_attempt_two = _execution( + run_id=run_id, + node_id=node_id, + definition_task_id=definition_task_id, + attempt_number=2, + started_at=now, + message="attempt-two-old", + ) + newer_attempt_two = _execution( + run_id=run_id, + node_id=node_id, + definition_task_id=definition_task_id, + attempt_number=2, + started_at=now + timedelta(minutes=5), + message="attempt-two-new", + ) + session.add_all([older_attempt_two, newer_attempt_two]) + session.commit() + + latest = TaskExecutionRepository().latest_for_definition_task( + session, + run_id, + definition_task_id, + ) + + assert latest is not None + assert latest.id == newer_attempt_two.id diff --git a/tests/unit/runtime/test_worker_execute_factory_call.py b/ergon_core/tests/unit/runtime/test_worker_execute_factory_call.py similarity index 100% rename from tests/unit/runtime/test_worker_execute_factory_call.py rename to ergon_core/tests/unit/runtime/test_worker_execute_factory_call.py diff --git a/ergon_core/tests/unit/runtime/test_worker_execute_stream_contract.py b/ergon_core/tests/unit/runtime/test_worker_execute_stream_contract.py new file mode 100644 index 00000000..a0a1b5ec --- /dev/null +++ b/ergon_core/tests/unit/runtime/test_worker_execute_stream_contract.py @@ -0,0 +1,83 @@ +from collections.abc import AsyncGenerator + +import pytest +from ergon_core.api.worker import WorkerOutput +from ergon_core.core.domain.generation.context_parts import AssistantTextPart, ContextPartChunk +from ergon_core.core.infrastructure.inngest.errors import ContractViolationError +from ergon_core.core.application.jobs.worker_execute import _consume_worker_stream + + +async def _stream_with_terminal_output() -> AsyncGenerator[ContextPartChunk | WorkerOutput, None]: + yield ContextPartChunk(part=AssistantTextPart(content="transcript")) + yield WorkerOutput(output="final result", success=True) + + +async def _stream_without_terminal_output() -> AsyncGenerator[ContextPartChunk | WorkerOutput, None]: + yield ContextPartChunk(part=AssistantTextPart(content="transcript")) + + +async def _stream_after_terminal_output() -> AsyncGenerator[ContextPartChunk | WorkerOutput, None]: + yield WorkerOutput(output="final result", success=True) + yield ContextPartChunk(part=AssistantTextPart(content="late transcript")) + + +async def _stream_with_multiple_outputs() -> AsyncGenerator[ContextPartChunk | WorkerOutput, None]: + yield WorkerOutput(output="first", success=True) + yield WorkerOutput(output="second", success=True) + + +async def _stream_with_invalid_item() -> AsyncGenerator[object, None]: + yield object() + yield WorkerOutput(output="final result", success=True) + + +@pytest.mark.asyncio +async def test_consume_worker_stream_persists_chunks_and_returns_terminal_output() -> None: + persisted: list[tuple[int, ContextPartChunk]] = [] + + async def persist(chunk: ContextPartChunk, chunk_count: int) -> None: + persisted.append((chunk_count, chunk)) + + output, chunk_count = await _consume_worker_stream(_stream_with_terminal_output(), persist) + + assert output == WorkerOutput(output="final result", success=True) + assert chunk_count == 1 + assert persisted == [ + (0, ContextPartChunk(part=AssistantTextPart(content="transcript"))), + ] + + +@pytest.mark.asyncio +async def test_consume_worker_stream_requires_terminal_output() -> None: + async def persist(chunk: ContextPartChunk, chunk_count: int) -> None: + pass + + with pytest.raises(ContractViolationError, match="terminal WorkerOutput"): + await _consume_worker_stream(_stream_without_terminal_output(), persist) + + +@pytest.mark.asyncio +async def test_consume_worker_stream_rejects_chunks_after_terminal_output() -> None: + async def persist(chunk: ContextPartChunk, chunk_count: int) -> None: + pass + + with pytest.raises(ContractViolationError, match="after terminal WorkerOutput"): + await _consume_worker_stream(_stream_after_terminal_output(), persist) + + +@pytest.mark.asyncio +async def test_consume_worker_stream_rejects_multiple_terminal_outputs() -> None: + async def persist(chunk: ContextPartChunk, chunk_count: int) -> None: + pass + + with pytest.raises(ContractViolationError, match="multiple terminal WorkerOutput"): + await _consume_worker_stream(_stream_with_multiple_outputs(), persist) + + +@pytest.mark.asyncio +async def test_consume_worker_stream_rejects_non_context_items() -> None: + async def persist(chunk: ContextPartChunk, chunk_count: int) -> None: + pass + + with pytest.raises(ContractViolationError, match="expected ContextPartChunk"): + await _consume_worker_stream(_stream_with_invalid_item(), persist) diff --git a/tests/unit/runtime/test_workflow_service.py b/ergon_core/tests/unit/runtime/test_workflow_service.py similarity index 98% rename from tests/unit/runtime/test_workflow_service.py rename to ergon_core/tests/unit/runtime/test_workflow_service.py index ff529174..0e1e1efd 100644 --- a/tests/unit/runtime/test_workflow_service.py +++ b/ergon_core/tests/unit/runtime/test_workflow_service.py @@ -14,7 +14,7 @@ RunResource, RunTaskExecution, ) -from ergon_core.core.runtime.services.workflow_service import WorkflowService +from ergon_core.core.application.workflows.service import WorkflowService from sqlalchemy.pool import StaticPool from sqlmodel import Session, SQLModel, create_engine, select @@ -445,6 +445,10 @@ async def test_materialize_resource_rejects_parent_directory_destination( @pytest.mark.asyncio async def test_add_task_dry_run_does_not_write_node() -> None: + from ergon_builtins.registry_core import register_core_builtins + from ergon_core.api.registry import registry + + register_core_builtins(registry) session = _session() run_id = _run(session) parent = _node(run_id=run_id, slug="parent", level=1) @@ -473,6 +477,10 @@ async def test_add_task_dry_run_does_not_write_node() -> None: @pytest.mark.asyncio async def test_add_task_writes_node_and_mutation() -> None: + from ergon_builtins.registry_core import register_core_builtins + from ergon_core.api.registry import registry + + register_core_builtins(registry) session = _session() run_id = _run(session) parent = _node(run_id=run_id, slug="parent", level=1) diff --git a/tests/unit/runtime/__init__.py b/ergon_core/tests/unit/sandbox/__init__.py similarity index 100% rename from tests/unit/runtime/__init__.py rename to ergon_core/tests/unit/sandbox/__init__.py diff --git a/tests/unit/sandbox/test_ensure_sandbox_idempotence.py b/ergon_core/tests/unit/sandbox/test_ensure_sandbox_idempotence.py similarity index 94% rename from tests/unit/sandbox/test_ensure_sandbox_idempotence.py rename to ergon_core/tests/unit/sandbox/test_ensure_sandbox_idempotence.py index 2f17bd82..bef51c7e 100644 --- a/tests/unit/sandbox/test_ensure_sandbox_idempotence.py +++ b/ergon_core/tests/unit/sandbox/test_ensure_sandbox_idempotence.py @@ -12,7 +12,7 @@ from uuid import UUID, uuid4 import pytest -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager class _ProbeManager(BaseSandboxManager): @@ -83,11 +83,11 @@ async def test_install_dependencies_runs_exactly_once_on_repeated_create( # `AsyncSandbox` binding in `manager.py` to return our fake sandbox. fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) diff --git a/tests/unit/sandbox/test_sandbox_lifecycle_service.py b/ergon_core/tests/unit/sandbox/test_sandbox_lifecycle_service.py similarity index 83% rename from tests/unit/sandbox/test_sandbox_lifecycle_service.py rename to ergon_core/tests/unit/sandbox/test_sandbox_lifecycle_service.py index fd44f1af..52a37766 100644 --- a/tests/unit/sandbox/test_sandbox_lifecycle_service.py +++ b/ergon_core/tests/unit/sandbox/test_sandbox_lifecycle_service.py @@ -1,7 +1,7 @@ from unittest.mock import AsyncMock, patch import pytest -from ergon_core.core.sandbox.lifecycle import ( +from ergon_core.core.infrastructure.sandbox.lifecycle import ( SandboxTerminationReason, terminate_sandbox_by_id, ) @@ -10,7 +10,7 @@ @pytest.mark.asyncio async def test_terminate_sandbox_by_id_dispatches_real_ids() -> None: with patch( - "ergon_core.core.sandbox.manager.BaseSandboxManager.terminate_by_sandbox_id", + "ergon_core.core.infrastructure.sandbox.manager.BaseSandboxManager.terminate_by_sandbox_id", new=AsyncMock(return_value=True), ) as terminate: result = await terminate_sandbox_by_id("sbx-live-123") diff --git a/tests/unit/sandbox/test_sandbox_reconnect.py b/ergon_core/tests/unit/sandbox/test_sandbox_reconnect.py similarity index 86% rename from tests/unit/sandbox/test_sandbox_reconnect.py rename to ergon_core/tests/unit/sandbox/test_sandbox_reconnect.py index c819fcf8..3cbe59ac 100644 --- a/tests/unit/sandbox/test_sandbox_reconnect.py +++ b/ergon_core/tests/unit/sandbox/test_sandbox_reconnect.py @@ -8,8 +8,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from ergon_core.core.sandbox.errors import SandboxExpiredError -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.errors import SandboxExpiredError +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager class _MinimalManager(BaseSandboxManager): @@ -51,11 +51,11 @@ async def test_reconnect_returns_sandbox_on_success( fake_sandbox = MagicMock() fake_connect = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -82,11 +82,11 @@ async def test_reconnect_does_not_register_in_sandboxes_dict( fake_sandbox = MagicMock() fake_connect = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -112,11 +112,11 @@ async def test_reconnect_idempotent_returns_equivalent_handles( fake_sandbox_b = MagicMock() fake_connect = AsyncMock(side_effect=[fake_sandbox_a, fake_sandbox_b]) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -134,7 +134,7 @@ async def test_reconnect_raises_sandbox_expired_on_not_found_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """SandboxNotFoundException → SandboxExpiredError with sandbox_id preserved.""" - import ergon_core.core.sandbox.manager as mgr_mod + import ergon_core.core.infrastructure.sandbox.manager as mgr_mod class _FakeSandboxNotFound(Exception): pass @@ -165,7 +165,7 @@ async def test_reconnect_raises_sandbox_expired_on_timeout_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """TimeoutException → SandboxExpiredError.""" - import ergon_core.core.sandbox.manager as mgr_mod + import ergon_core.core.infrastructure.sandbox.manager as mgr_mod class _FakeTimeout(Exception): pass @@ -198,11 +198,11 @@ async def test_reconnect_classifies_by_message_when_sdk_raises_generic_error( side_effect=Exception("HTTP 404: sandbox not found"), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -225,11 +225,11 @@ async def test_reconnect_reraises_unrelated_errors_unchanged( """ fake_connect = AsyncMock(side_effect=ConnectionError("TLS handshake failed")) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(connect=fake_connect), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) diff --git a/tests/unit/sandbox/test_stub_sandbox_id.py b/ergon_core/tests/unit/sandbox/test_stub_sandbox_id.py similarity index 100% rename from tests/unit/sandbox/test_stub_sandbox_id.py rename to ergon_core/tests/unit/sandbox/test_stub_sandbox_id.py diff --git a/tests/unit/test_app_mounts_harness_conditionally.py b/ergon_core/tests/unit/test_app_mounts_harness_conditionally.py similarity index 97% rename from tests/unit/test_app_mounts_harness_conditionally.py rename to ergon_core/tests/unit/test_app_mounts_harness_conditionally.py index ac9ac39a..c7c2b05a 100644 --- a/tests/unit/test_app_mounts_harness_conditionally.py +++ b/ergon_core/tests/unit/test_app_mounts_harness_conditionally.py @@ -14,7 +14,7 @@ def _reload_app_with(monkeypatch: pytest.MonkeyPatch, env_value: str | None): else: monkeypatch.setenv("ENABLE_TEST_HARNESS", env_value) # reason: import after env mutation so the reload sees ENABLE_TEST_HARNESS - import ergon_core.core.api.app as app_mod + import ergon_core.core.rest_api.app as app_mod importlib.reload(app_mod) return app_mod.app diff --git a/tests/unit/test_dashboard_emitter_wiring.py b/ergon_core/tests/unit/test_dashboard_emitter_wiring.py similarity index 95% rename from tests/unit/test_dashboard_emitter_wiring.py rename to ergon_core/tests/unit/test_dashboard_emitter_wiring.py index 3f080195..551abff4 100644 --- a/tests/unit/test_dashboard_emitter_wiring.py +++ b/ergon_core/tests/unit/test_dashboard_emitter_wiring.py @@ -13,7 +13,7 @@ from typing import Final import pytest -from ergon_core.core.dashboard.emitter import DashboardEmitter +from ergon_core.core.infrastructure.dashboard.emitter import DashboardEmitter # --------------------------------------------------------------------------- # Configuration @@ -30,9 +30,9 @@ # inside emitter.py don't count as call sites. _DEFINITION_FILES: Final[frozenset[str]] = frozenset( { - "ergon_core/ergon_core/core/dashboard/emitter.py", - "ergon_core/ergon_core/core/dashboard/event_contracts.py", - "ergon_core/ergon_core/core/sandbox/event_sink.py", + "ergon_core/ergon_core/core/infrastructure/dashboard/emitter.py", + "ergon_core/ergon_core/core/infrastructure/dashboard/event_contracts.py", + "ergon_core/ergon_core/core/infrastructure/sandbox/event_sink.py", } ) diff --git a/tests/unit/test_rollouts_di.py b/ergon_core/tests/unit/test_rollouts_di.py similarity index 96% rename from tests/unit/test_rollouts_di.py rename to ergon_core/tests/unit/test_rollouts_di.py index e542ae1b..29d70324 100644 --- a/tests/unit/test_rollouts_di.py +++ b/ergon_core/tests/unit/test_rollouts_di.py @@ -1,6 +1,6 @@ from uuid import uuid4 -from ergon_core.core.api.rollouts import router +from ergon_core.core.rest_api.rollouts import router from fastapi import FastAPI from fastapi.testclient import TestClient diff --git a/tests/unit/test_swebench_criterion_no_sandbox.py b/ergon_core/tests/unit/test_swebench_criterion_no_sandbox.py similarity index 91% rename from tests/unit/test_swebench_criterion_no_sandbox.py rename to ergon_core/tests/unit/test_swebench_criterion_no_sandbox.py index 9d12ef61..414ffd60 100644 --- a/tests/unit/test_swebench_criterion_no_sandbox.py +++ b/ergon_core/tests/unit/test_swebench_criterion_no_sandbox.py @@ -21,10 +21,10 @@ import pytest from ergon_builtins.benchmarks.swebench_verified.criterion import SWEBenchTestCriterion from ergon_builtins.benchmarks.swebench_verified.task_schemas import SWEBenchTaskPayload -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import WorkerOutput -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.evaluation.protocols import CommandResult +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.worker import WorkerOutput +from ergon_core.api.benchmark import Task +from ergon_core.core.application.evaluation.protocols import CommandResult def _task_payload() -> SWEBenchTaskPayload: @@ -42,8 +42,8 @@ def _task_payload() -> SWEBenchTaskPayload: ) -def _task() -> BenchmarkTask[SWEBenchTaskPayload]: - return BenchmarkTask[SWEBenchTaskPayload]( +def _task() -> Task[SWEBenchTaskPayload]: + return Task[SWEBenchTaskPayload]( task_slug="swe-001", instance_key="default", description="Fix the bug", @@ -73,7 +73,7 @@ async def test_evaluate_calls_ensure_sandbox_not_spawn_eval_sandbox() -> None: ) ) - ctx = EvaluationContext( + ctx = CriterionContext( run_id=uuid4(), task_id=uuid4(), execution_id=uuid4(), diff --git a/tests/unit/test_test_harness.py b/ergon_core/tests/unit/test_test_harness.py similarity index 93% rename from tests/unit/test_test_harness.py rename to ergon_core/tests/unit/test_test_harness.py index 027463de..abb413b2 100644 --- a/tests/unit/test_test_harness.py +++ b/ergon_core/tests/unit/test_test_harness.py @@ -4,9 +4,9 @@ from uuid import uuid4 import pytest -from ergon_core.core.api import test_harness -from ergon_core.core.api.app import _run_startup_plugins -from ergon_core.core.api.test_harness import get_session_dep, router +from ergon_core.core.rest_api import test_harness +from ergon_core.core.rest_api.app import _run_startup_plugins +from ergon_core.core.rest_api.test_harness import get_session_dep, router from fastapi import FastAPI from fastapi.testclient import TestClient @@ -112,4 +112,4 @@ def test_reset_requires_secret_header(monkeypatch: pytest.MonkeyPatch) -> None: def test_startup_plugin_loader_rejects_invalid_specs() -> None: with pytest.raises(RuntimeError, match="expected 'module:function'"): - _run_startup_plugins(("ergon_core.test_support.smoke_fixtures",)) + _run_startup_plugins(("tests.fixtures.smoke_components",)) diff --git a/package.json b/package.json index 6ad2eb43..5d6d614f 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "check:be:suppression-budget": "uv run python scripts/check_suppression_budget.py", "check:be:complexity": "uv run xenon --max-absolute F --max-modules E --max-average C ergon_core ergon_builtins ergon_cli ergon_infra", "check:be": "pnpm run check:be:lint && pnpm run check:be:fmt && pnpm run check:be:type && pnpm run check:be:slopcop && pnpm run check:be:suppression-budget && pnpm run check:be:complexity", - "test:be:unit": "uv run pytest tests/unit -q -n auto --durations=20", - "test:be:coverage": "uv run pytest tests/unit tests/integration --cov=ergon_core --cov=ergon_builtins --cov-report=term-missing --cov-report=xml:coverage.xml", + "test:be:unit": "uv run pytest ergon_core/tests/unit ergon_builtins/tests/unit ergon_cli/tests/unit tests/unit -q -n auto --durations=20", + "test:be:coverage": "uv run pytest ergon_core/tests/unit ergon_builtins/tests/unit ergon_cli/tests/unit tests/unit tests/integration --cov=ergon_core --cov=ergon_builtins --cov-report=term-missing --cov-report=xml:coverage.xml", "test:be:integration": "uv run pytest tests/integration -v --timeout=300", "test:be:all": "pnpm run test:be:unit && pnpm run test:be:integration", "test:be:e2e": "ERGON_DATABASE_URL=${ERGON_DATABASE_URL:-postgresql://ergon:ergon_dev@localhost:5433/ergon} ERGON_API_BASE_URL=${ERGON_API_BASE_URL:-http://127.0.0.1:9000} TEST_HARNESS_SECRET=${TEST_HARNESS_SECRET:-local-dev} uv run pytest tests/e2e -v", diff --git a/scripts/export_contract_schemas.py b/scripts/export_contract_schemas.py index 64bcc4be..e2acef34 100644 --- a/scripts/export_contract_schemas.py +++ b/scripts/export_contract_schemas.py @@ -1,7 +1,7 @@ """Export dashboard Inngest event contracts as JSON Schema. Reads ergon-dashboard/src/generated/events/schemas/manifest.json, imports each -listed pydantic model from ergon_core.core.dashboard.event_contracts, and +listed pydantic model from ergon_core.core.infrastructure.dashboard.event_contracts, and writes its JSON schema next to the manifest. Downstream, json-schema-to-zod turns these into the dashboard's Zod validators (see package.json's generate:contracts:events step). @@ -19,7 +19,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent SCHEMA_DIR = REPO_ROOT / "ergon-dashboard" / "src" / "generated" / "events" / "schemas" MANIFEST_PATH = SCHEMA_DIR / "manifest.json" -CONTRACTS_MODULE = "ergon_core.core.dashboard.event_contracts" +CONTRACTS_MODULE = "ergon_core.core.infrastructure.dashboard.event_contracts" def main() -> None: diff --git a/scripts/smoke_local_up.sh b/scripts/smoke_local_up.sh index e1d24e0d..13d8c1bd 100755 --- a/scripts/smoke_local_up.sh +++ b/scripts/smoke_local_up.sh @@ -48,7 +48,7 @@ Stack is up. Export these in your shell before running smoke: export ERGON_API_BASE_URL=http://127.0.0.1:9000 export PLAYWRIGHT_BASE_URL=http://127.0.0.1:3001 export ENABLE_TEST_HARNESS=1 - export ERGON_STARTUP_PLUGINS=ergon_core.test_support.smoke_fixtures:register_smoke_fixtures + export ERGON_STARTUP_PLUGINS=ergon_builtins.registry:register_builtins,tests.fixtures.smoke_components:register_smoke_fixtures export TEST_HARNESS_SECRET=local-dev export SCREENSHOT_DIR=/tmp/playwright export E2B_API_KEY= # required for real sandbox runs diff --git a/scripts/smoke_reassert.py b/scripts/smoke_reassert.py index 07d9a5c7..a770bddc 100755 --- a/scripts/smoke_reassert.py +++ b/scripts/smoke_reassert.py @@ -37,7 +37,7 @@ # Register smoke fixtures so runtime ClassVars (PARENT_TURN_COUNT etc.) are # wired up identically to how the driver sees them. -from ergon_core.test_support.smoke_fixtures import register_smoke_fixtures +from tests.fixtures.smoke_components import register_smoke_fixtures register_smoke_fixtures() from tests.e2e._asserts import ( diff --git a/tests/conftest.py b/tests/conftest.py index 7c035d5c..f41805fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ from collections.abc import Iterator import pytest -from ergon_core.core.dashboard.provider import init_dashboard_emitter, reset_dashboard_emitter +from ergon_core.core.infrastructure.dashboard.provider import init_dashboard_emitter, reset_dashboard_emitter @pytest.fixture(autouse=True) diff --git a/tests/e2e/_asserts.py b/tests/e2e/_asserts.py index b58b771a..b6148fe7 100644 --- a/tests/e2e/_asserts.py +++ b/tests/e2e/_asserts.py @@ -8,10 +8,8 @@ See docs/superpowers/plans/test-refactor/02-drivers-and-asserts.md §2 and §10 for the full catalogue. -Schema paths are best-effort sketches against the current -``ergon_core.core.persistence.*`` models; if a table name moves, fix -the import + query inline rather than pushing complexity into this -module. +Persistence-specific reads live behind ``ergon_core.test_support`` so +these e2e assertions stay stable while private core modules move. """ from __future__ import annotations @@ -21,33 +19,34 @@ import json import os import time -from pathlib import Path from uuid import UUID import httpx -from ergon_core.core.api.schemas import RunTaskDto -from ergon_core.core.persistence.graph.models import RunGraphNode -from ergon_core.core.persistence.graph.status_conventions import BLOCKED, COMPLETED, FAILED -from ergon_core.core.persistence.shared.db import get_session -from ergon_core.core.persistence.telemetry.models import ( - RunResource, - RunTaskEvaluation, - RunTaskExecution, - SandboxCommandWalEntry, - SandboxEvent, +from ergon_core.core.application.read_models.models import RunTaskDto +from ergon_core.test_support.e2e_read_helpers import ( + ResourceSnapshot, + first_probe_resource, + leaf_execution_timings_by_slug, + list_named_resources, + list_root_execution_and_evaluations, + list_sandbox_command_wal, + list_sandbox_events, + read_resource_bytes, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.constants import EXPECTED_SUBTASK_SLUGS -from ergon_core.test_support.smoke_fixtures.smoke_base.leaf_base import BaseSmokeLeafWorker -from ergon_core.test_support.smoke_fixtures.smoke_base.recursive import ( +from tests.fixtures.smoke_components.smoke_base.constants import EXPECTED_SUBTASK_SLUGS +from tests.fixtures.smoke_components.smoke_base.leaf_base import BaseSmokeLeafWorker +from tests.fixtures.smoke_components.smoke_base.recursive import ( NESTED_LINE_SLUGS, RecursiveSmokeWorkerBase, ) -from ergon_core.test_support.smoke_fixtures.smoke_base.worker_base import SmokeWorkerBase -from sqlmodel import select +from tests.fixtures.smoke_components.smoke_base.worker_base import SmokeWorkerBase from tests.e2e._read_contracts import require_run_snapshot TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"}) +BLOCKED = "blocked" +COMPLETED = "completed" +FAILED = "failed" # ============================================================================= @@ -160,25 +159,10 @@ def _assert_run_evaluation(run_id: UUID) -> None: execution completed. """ deadline = time.monotonic() + 30 - evaluations: list[RunTaskEvaluation] = [] - root_execution: RunTaskExecution | None = None + evaluations = [] + root_execution = None while time.monotonic() < deadline: - with get_session() as s: - root = s.exec( - select(RunGraphNode) - .where(RunGraphNode.run_id == run_id) - .where(RunGraphNode.level == 0), - ).one() - root_execution = s.exec( - select(RunTaskExecution).where(RunTaskExecution.node_id == root.id), - ).first() - evaluations = list( - s.exec( - select(RunTaskEvaluation) - .where(RunTaskEvaluation.run_id == run_id) - .where(RunTaskEvaluation.node_id == root.id), - ).all(), - ) + root_execution, evaluations = list_root_execution_and_evaluations(run_id) if len(evaluations) == 2: break time.sleep(2) @@ -217,12 +201,7 @@ def _assert_run_evaluation(run_id: UUID) -> None: def _assert_sandbox_command_wal(run_id: UUID) -> None: """Bash commands land as WAL rows via ``PostgresSandboxEventSink``.""" - with get_session() as s: - entries = list( - s.exec( - select(SandboxCommandWalEntry).where(SandboxCommandWalEntry.run_id == run_id), - ).all(), - ) + entries = list_sandbox_command_wal(run_id) probes = [e for e in entries if "wc" in e.command or "probe" in e.command] # Canonical sad-path smokes block l_3 before it starts, so the eight # executed leaves should emit probe commands while l_3 emits none. @@ -232,10 +211,9 @@ def _assert_sandbox_command_wal(run_id: UUID) -> None: def _assert_sandbox_lifecycle_events(run_id: UUID) -> None: """``sandbox_created`` + ``sandbox_closed`` symmetric per sandbox.""" deadline = time.monotonic() + 30 - events: list[SandboxEvent] = [] + events = [] while time.monotonic() < deadline: - with get_session() as s: - events = list(s.exec(select(SandboxEvent).where(SandboxEvent.run_id == run_id)).all()) + events = list_sandbox_events(run_id) created = {e.sandbox_id for e in events if e.kind == "sandbox_created"} closed = {e.sandbox_id for e in events if e.kind == "sandbox_closed"} if created == closed: @@ -276,23 +254,11 @@ def _assert_blob_roundtrip(run_id: UUID) -> None: legacy ``kind='output'`` rows store container-internal download paths that are not directly accessible from the host-side test process. """ - with get_session() as s: - row = s.exec( - select(RunResource) - .where(RunResource.run_id == run_id) - .where( - RunResource.name.like("probe_%.json"), # ty: ignore[unresolved-attribute] - ) - .where(RunResource.kind == "report") - .order_by( - RunResource.created_at, # ty: ignore[unresolved-attribute] - ) - .limit(1), - ).first() + row = first_probe_resource(run_id) assert row is not None, "no probe_*.json (kind=report) to round-trip" assert row.content_hash - bytes_a = Path(row.file_path).read_bytes() - bytes_b = Path(row.file_path).read_bytes() + bytes_a = read_resource_bytes(row) + bytes_b = read_resource_bytes(row) assert bytes_a == bytes_b, "blob read non-deterministic" parsed = json.loads(bytes_a) assert "exit_code" in parsed, f"probe JSON missing exit_code: {parsed!r}" @@ -302,7 +268,7 @@ def _assert_minif2f_artifacts(run_id: UUID) -> None: """Every MiniF2F leaf persists a Lean proof artifact with the smoke theorem.""" resources = _require_named_resources(run_id, prefix="proof_", suffix=".lean", expected_count=10) for resource in resources: - text = Path(resource.file_path).read_bytes().decode("utf-8") + text = read_resource_bytes(resource).decode("utf-8") assert "theorem smoke_trivial" in text, f"{resource.name} missing theorem marker" assert ":=" in text, f"{resource.name} missing Lean proof term" @@ -311,7 +277,7 @@ def _assert_swebench_artifacts(run_id: UUID) -> None: """Every SWE-Bench leaf persists a parseable Python patch with add().""" resources = _require_named_resources(run_id, prefix="patch_", suffix=".py", expected_count=10) for resource in resources: - source = Path(resource.file_path).read_bytes().decode("utf-8") + source = read_resource_bytes(resource).decode("utf-8") module = ast.parse(source, filename=resource.name) function_names = { node.name for node in ast.walk(module) if isinstance(node, ast.FunctionDef) @@ -325,17 +291,8 @@ def _require_named_resources( prefix: str, suffix: str, expected_count: int, -) -> list[RunResource]: - with get_session() as s: - resources = list( - s.exec( - select(RunResource) - .where(RunResource.run_id == run_id) - .where( - RunResource.name.like(f"{prefix}%{suffix}"), # ty: ignore[unresolved-attribute] - ), - ).all(), - ) +) -> list[ResourceSnapshot]: + resources = list_named_resources(run_id, prefix=prefix, suffix=suffix) assert len(resources) == expected_count, ( f"expected {expected_count} {prefix}*{suffix} resources, got {len(resources)}" ) @@ -352,25 +309,7 @@ def _assert_temporal_ordering(run_id: UUID) -> None: at least ``started`` state. Blocked descendants are skipped because they should never have execution timestamps. """ - with get_session() as s: - leaves = list( - s.exec( - select(RunGraphNode) - .where(RunGraphNode.run_id == run_id) - .where(RunGraphNode.level > 0), - ).all(), - ) - executions = list( - s.exec( - select(RunTaskExecution) - .where(RunTaskExecution.run_id == run_id) - .where( - RunTaskExecution.node_id.in_([leaf.id for leaf in leaves]), # ty: ignore[unresolved-attribute] - ), - ).all(), - ) - by_node = {e.node_id: e for e in executions if e.node_id is not None} - slug_exec = {leaf.task_slug: by_node.get(leaf.id) for leaf in leaves} + slug_exec = leaf_execution_timings_by_slug(run_id) def _after(child: str, parents: list[str]) -> None: c_exec = slug_exec.get(child) @@ -445,18 +384,9 @@ def _assert_sadpath_partial_artifact(run_id: UUID) -> None: """``AlwaysFailSubworker`` writes ``partial_.md`` before raising. The runtime's persist step must still serialize it as a RunResource.""" deadline = time.monotonic() + 30 - partials: list[RunResource] = [] + partials: list[ResourceSnapshot] = [] while time.monotonic() < deadline: - with get_session() as s: - partials = list( - s.exec( - select(RunResource) - .where(RunResource.run_id == run_id) - .where( - RunResource.name.like("partial_%.md"), # ty: ignore[unresolved-attribute] - ), - ).all(), - ) + partials = list_named_resources(run_id, prefix="partial_", suffix=".md") if partials: break time.sleep(2) @@ -466,21 +396,16 @@ def _assert_sadpath_partial_artifact(run_id: UUID) -> None: ) r = partials[0] assert r.content_hash, "partial resource missing content_hash" - body = Path(r.file_path).read_bytes().decode("utf-8") + body = read_resource_bytes(r).decode("utf-8") assert body.startswith("# Partial work"), f"partial artifact body unexpected: {body[:80]!r}" def _assert_sadpath_partial_wal(run_id: UUID) -> None: """Pre-failure ``wc -l partial_*`` command persists as WAL row.""" deadline = time.monotonic() + 30 - wc: list[SandboxCommandWalEntry] = [] + wc = [] while time.monotonic() < deadline: - with get_session() as s: - entries = list( - s.exec( - select(SandboxCommandWalEntry).where(SandboxCommandWalEntry.run_id == run_id), - ).all(), - ) + entries = list_sandbox_command_wal(run_id) wc = [e for e in entries if "wc -l" in e.command and "partial_" in e.command] if wc: break diff --git a/tests/e2e/_read_contracts.py b/tests/e2e/_read_contracts.py index 91518beb..71bc6f3b 100644 --- a/tests/e2e/_read_contracts.py +++ b/tests/e2e/_read_contracts.py @@ -4,8 +4,8 @@ from uuid import UUID -from ergon_core.core.api.schemas import RunSnapshotDto -from ergon_core.core.runtime.services.run_read_service import RunReadService +from ergon_core.core.application.read_models.models import RunSnapshotDto +from ergon_core.core.application.read_models.runs import RunReadService def require_run_snapshot(run_id: UUID) -> RunSnapshotDto: diff --git a/tests/e2e/_submit.py b/tests/e2e/_submit.py index 65023de9..e7a8a340 100644 --- a/tests/e2e/_submit.py +++ b/tests/e2e/_submit.py @@ -40,11 +40,36 @@ def _api_base() -> str: return os.environ.get("ERGON_API_BASE_URL", _DEFAULT_API) +def build_cohort_payload( + *, + benchmark_slug: str, + slots: list[tuple[str, str]], + cohort_key: str, + sandbox_slug: str, + dependency_extras: tuple[str, ...], + model: str = "openai:gpt-4o", +) -> dict: + return { + "benchmark_slug": benchmark_slug, + "slots": [ + {"worker_slug": worker, "evaluator_slug": criterion} + for worker, criterion in slots + ], + "cohort_key": cohort_key, + "sandbox_slug": sandbox_slug, + "dependency_extras": list(dependency_extras), + "model": model, + } + + async def submit_cohort( *, benchmark_slug: str, slots: list[tuple[str, str]], cohort_key: str, + sandbox_slug: str, + dependency_extras: tuple[str, ...], + model: str = "openai:gpt-4o", timeout: int = 300, # reserved — server-side per-run timeout ) -> list[UUID]: """Submit one run per slot under ``cohort_key``; return run_ids in order. @@ -53,17 +78,21 @@ async def submit_cohort( benchmark_slug: e.g. ``"researchrubrics"`` slots: list of ``(worker_slug, criterion_slug)`` tuples cohort_key: shared cohort name (all runs group under this) + sandbox_slug: explicit sandbox manager slug for the run + dependency_extras: explicit dependency intent; smoke uses ``("none",)`` + model: explicit model target used by the test harness timeout: reserved for future use; the api endpoint does not block on run completion, so there is no client-side timeout to propagate. """ - payload = { - "benchmark_slug": benchmark_slug, - "slots": [ - {"worker_slug": worker, "evaluator_slug": criterion} for worker, criterion in slots - ], - "cohort_key": cohort_key, - } + payload = build_cohort_payload( + benchmark_slug=benchmark_slug, + slots=slots, + cohort_key=cohort_key, + sandbox_slug=sandbox_slug, + dependency_extras=dependency_extras, + model=model, + ) async with httpx.AsyncClient(base_url=_api_base(), timeout=30.0) as client: response = await client.post("/api/test/write/cohort", json=payload) if response.status_code >= 400: @@ -77,4 +106,4 @@ async def submit_cohort( return [UUID(rid) for rid in body["run_ids"]] -__all__ = ["smoke_cohort_key", "submit_cohort"] +__all__ = ["build_cohort_payload", "smoke_cohort_key", "submit_cohort"] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 48b99481..43eaec83 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -11,9 +11,7 @@ from urllib.parse import urlparse import pytest -from ergon_core.core.persistence.shared.db import get_engine -from ergon_core.core.settings import settings -from sqlmodel import Session +from ergon_core.core.shared.settings import settings _UUID_RE = re.compile( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", @@ -83,20 +81,13 @@ def _require_infra(): pytest.fail("\n".join(lines)) -@pytest.fixture(scope="session") -def db_session(): - """Provide a raw SQLModel session for assertion queries.""" - engine = get_engine() - session = Session(engine) - yield session - session.close() - - def run_benchmark( slug: str, *, worker: str, evaluator: str, + sandbox: str, + extras: str = "none", model: str = "stub:constant", limit: int = 1, cohort: str = "ci", @@ -114,6 +105,10 @@ def run_benchmark( model, "--evaluator", evaluator, + "--sandbox", + sandbox, + "--extras", + extras, "--limit", str(limit), "--cohort", @@ -152,30 +147,34 @@ def _parse_uuid_line(prefix: str, output: str) -> str: @pytest.fixture(scope="session") def benchmarked(): - """Memoize `run_benchmark` calls by (slug, worker, evaluator, cohort). + """Memoize `run_benchmark` calls by explicit runtime configuration. The stubbed E2E tests each assert against the *latest* RunRecord; re-running the same benchmark per-test burned ~4× subprocess launches with identical outcomes. This fixture runs each unique config exactly once per session and returns the cached `CompletedProcess`. """ - cache: dict[tuple[str, str, str, str], subprocess.CompletedProcess] = {} + cache: dict[tuple[str, str, str, str, str, str], subprocess.CompletedProcess] = {} def _run( slug: str, *, worker: str, evaluator: str, + sandbox: str, + extras: str = "none", limit: int = 1, cohort: str = "ci", timeout: int = 120, ) -> subprocess.CompletedProcess: - key = (slug, worker, evaluator, cohort) + key = (slug, worker, evaluator, sandbox, extras, cohort) if key not in cache: cache[key] = run_benchmark( slug, worker=worker, evaluator=evaluator, + sandbox=sandbox, + extras=extras, limit=limit, cohort=cohort, timeout=timeout, diff --git a/tests/e2e/test_minif2f_smoke.py b/tests/e2e/test_minif2f_smoke.py index 90c3a488..7355faa6 100644 --- a/tests/e2e/test_minif2f_smoke.py +++ b/tests/e2e/test_minif2f_smoke.py @@ -63,6 +63,8 @@ async def test_smoke_cohort(tmp_path: pathlib.Path) -> None: benchmark_slug=ENV, slots=[(worker, criterion) for _, worker, criterion in smoke_slots], cohort_key=cohort_key, + sandbox_slug=ENV, + dependency_extras=("none",), timeout=PER_RUN_TIMEOUT, ) assert len(run_ids) == len(smoke_slots) diff --git a/tests/e2e/test_researchrubrics_smoke.py b/tests/e2e/test_researchrubrics_smoke.py index ca532e18..108a5ac3 100644 --- a/tests/e2e/test_researchrubrics_smoke.py +++ b/tests/e2e/test_researchrubrics_smoke.py @@ -74,6 +74,8 @@ async def test_smoke_cohort(tmp_path: pathlib.Path) -> None: benchmark_slug=ENV, slots=[(worker, criterion) for _, worker, criterion in smoke_slots], cohort_key=cohort_key, + sandbox_slug=ENV, + dependency_extras=("none",), timeout=PER_RUN_TIMEOUT, ) assert len(run_ids) == len(smoke_slots) diff --git a/tests/e2e/test_swebench_smoke.py b/tests/e2e/test_swebench_smoke.py index 4d3e2555..6563c3bd 100644 --- a/tests/e2e/test_swebench_smoke.py +++ b/tests/e2e/test_swebench_smoke.py @@ -68,6 +68,8 @@ async def test_smoke_cohort(tmp_path: pathlib.Path) -> None: benchmark_slug=ENV, slots=[(worker, criterion) for _, worker, criterion in smoke_slots], cohort_key=cohort_key, + sandbox_slug=ENV, + dependency_extras=("none",), timeout=PER_RUN_TIMEOUT, ) assert len(run_ids) == len(smoke_slots) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4d18d38b..cb497602 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -27,8 +27,8 @@ import pytest import pytest_asyncio from ergon_core.core.persistence.shared.db import ensure_db -from ergon_core.core.runtime.inngest.client import inngest_client -from ergon_core.core.settings import settings +from ergon_core.core.infrastructure.inngest.client import inngest_client +from ergon_core.core.shared.settings import settings from inngest._internal import net as inngest_net diff --git a/tests/integration/minif2f/test_sandbox_manager.py b/tests/integration/minif2f/test_sandbox_manager.py index 61bf878f..6dad573a 100644 --- a/tests/integration/minif2f/test_sandbox_manager.py +++ b/tests/integration/minif2f/test_sandbox_manager.py @@ -10,7 +10,7 @@ import pytest from ergon_builtins.benchmarks.minif2f.sandbox.utils import resolve_template from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager # --------------------------------------------------------------------------- # Reset the singleton between tests — BaseSandboxManager stores _instance and @@ -104,12 +104,12 @@ async def test_create_threads_template_kwarg_to_e2b_sdk( fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) # settings.e2b_api_key must be truthy for create() to proceed. monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -157,11 +157,11 @@ async def _run(cmd: str, **_kwargs: object) -> MagicMock: fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -188,11 +188,11 @@ async def test_base_class_omits_template_when_unset(monkeypatch: pytest.MonkeyPa ) fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) diff --git a/tests/integration/minif2f/test_verification_integration.py b/tests/integration/minif2f/test_verification_integration.py index 0f331741..1d79aad2 100644 --- a/tests/integration/minif2f/test_verification_integration.py +++ b/tests/integration/minif2f/test_verification_integration.py @@ -20,14 +20,14 @@ ) from ergon_builtins.benchmarks.minif2f.sandbox.utils import REGISTRY_PATH from ergon_builtins.benchmarks.minif2f.sandbox_manager import MiniF2FSandboxManager -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import WorkerOutput -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.core.sandbox.manager import BaseSandboxManager -from ergon_core.core.runtime.evaluation.criterion_runtime import ( +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.worker import WorkerOutput +from ergon_core.api.benchmark import EmptyTaskPayload, Task +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager +from ergon_core.core.application.evaluation.criterion_runtime import ( DefaultCriterionRuntime, ) -from ergon_core.core.runtime.evaluation.evaluation_schemas import CriterionContext +from ergon_core.core.application.evaluation.models import CriterionContext def _require_setup() -> None: @@ -55,8 +55,8 @@ def _reset_sandbox_singleton(): BaseSandboxManager._sandboxes = {} -def _make_task() -> BenchmarkTask: - return BenchmarkTask( +def _make_task() -> Task: + return Task( task_slug="mathd_algebra_176", instance_key="default", description=("theorem mathd_algebra_176 (x : ℝ) : (x + 1) ^ 2 * x = x ^ 3 + 2 * x ^ 2 + x"), @@ -101,7 +101,7 @@ async def test_fixture_proof_verifies_to_score_1() -> None: output="", success=True, ) - eval_ctx = EvaluationContext( + eval_ctx = CriterionContext( run_id=run_id, task_id=uuid4(), execution_id=uuid4(), diff --git a/tests/integration/propagation/test_add_subtask_dispatch.py b/tests/integration/propagation/test_add_subtask_dispatch.py index c23f460a..8eca48a2 100644 --- a/tests/integration/propagation/test_add_subtask_dispatch.py +++ b/tests/integration/propagation/test_add_subtask_dispatch.py @@ -10,17 +10,17 @@ RunId, TaskSlug, ) -from ergon_core.core.runtime.events.task_events import TaskReadyEvent -from ergon_core.core.runtime.services.task_management_dto import AddSubtaskCommand -from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from ergon_core.core.application.events.task_events import TaskReadyEvent +from ergon_core.core.application.tasks.models import AddSubtaskCommand +from ergon_core.core.application.tasks.management import TaskManagementService from tests.integration.propagation._helpers import make_experiment_definition, make_node, make_run from tests.integration.restart._helpers import cleanup_run pytestmark = pytest.mark.integration -_TMS_INNGEST = "ergon_core.core.runtime.services.task_management_service.inngest_client" -_EMITTER_INNGEST = "ergon_core.core.dashboard.emitter.inngest_client" +_TMS_INNGEST = "ergon_core.core.application.tasks.management.inngest_client" +_EMITTER_INNGEST = "ergon_core.core.infrastructure.dashboard.emitter.inngest_client" @pytest.mark.asyncio diff --git a/tests/integration/propagation/test_propagation_blocked.py b/tests/integration/propagation/test_propagation_blocked.py index e6de4cd0..21d83fbe 100644 --- a/tests/integration/propagation/test_propagation_blocked.py +++ b/tests/integration/propagation/test_propagation_blocked.py @@ -7,10 +7,10 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.orchestration_dto import PropagateTaskCompletionCommand -from ergon_core.core.runtime.services.task_propagation_service import TaskPropagationService +from ergon_core.core.application.graph.models import MutationMeta +from ergon_core.core.application.graph.repository import WorkflowGraphRepository +from ergon_core.core.application.workflows.orchestration import PropagateTaskCompletionCommand +from ergon_core.core.application.workflows.service import WorkflowService from sqlmodel import select from tests.integration.propagation._helpers import ( @@ -104,7 +104,7 @@ async def test_3_failure_cascade_successor_blocked() -> None: session.commit() # Propagate failure from B - svc = TaskPropagationService() + svc = WorkflowService() await svc.propagate_failure( PropagateTaskCompletionCommand( run_id=run_id, @@ -202,7 +202,7 @@ async def test_7_parent_failure_children_blocked() -> None: ) session.commit() - svc = TaskPropagationService() + svc = WorkflowService() await svc.propagate_failure( PropagateTaskCompletionCommand( run_id=run_id, @@ -292,7 +292,7 @@ async def test_10_blocked_propagates_transitively() -> None: ) session.commit() - svc = TaskPropagationService() + svc = WorkflowService() await svc.propagate_failure( PropagateTaskCompletionCommand( run_id=run_id, @@ -375,7 +375,7 @@ async def test_12_running_successor_not_interrupted() -> None: ) session.commit() - svc = TaskPropagationService() + svc = WorkflowService() await svc.propagate_failure( PropagateTaskCompletionCommand( run_id=run_id, diff --git a/tests/integration/propagation/test_propagation_cancel.py b/tests/integration/propagation/test_propagation_cancel.py index 54319751..95241cd1 100644 --- a/tests/integration/propagation/test_propagation_cancel.py +++ b/tests/integration/propagation/test_propagation_cancel.py @@ -15,8 +15,8 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository +from ergon_core.core.application.graph.models import MutationMeta +from ergon_core.core.application.graph.repository import WorkflowGraphRepository from sqlmodel import select from tests.integration.propagation._helpers import ( diff --git a/tests/integration/propagation/test_propagation_edge_cases.py b/tests/integration/propagation/test_propagation_edge_cases.py index bb72d1ed..ca81097a 100644 --- a/tests/integration/propagation/test_propagation_edge_cases.py +++ b/tests/integration/propagation/test_propagation_edge_cases.py @@ -11,10 +11,10 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.orchestration_dto import PropagateTaskCompletionCommand -from ergon_core.core.runtime.services.task_propagation_service import TaskPropagationService +from ergon_core.core.application.graph.models import MutationMeta +from ergon_core.core.application.graph.repository import WorkflowGraphRepository +from ergon_core.core.application.workflows.orchestration import PropagateTaskCompletionCommand +from ergon_core.core.application.workflows.service import WorkflowService from sqlmodel import select from tests.integration.propagation._helpers import ( @@ -109,7 +109,7 @@ async def test_ec1_fan_in_one_dep_fails_target_blocked() -> None: ) session.commit() - svc = TaskPropagationService() + svc = WorkflowService() # Propagate A's failure first await svc.propagate_failure( @@ -194,7 +194,7 @@ async def test_ec2_duplicate_propagate_is_idempotent() -> None: ) session.commit() - svc = TaskPropagationService() + svc = WorkflowService() command = PropagateTaskCompletionCommand( run_id=run_id, diff --git a/tests/integration/propagation/test_propagation_happy.py b/tests/integration/propagation/test_propagation_happy.py index a17f2952..a6fc5afa 100644 --- a/tests/integration/propagation/test_propagation_happy.py +++ b/tests/integration/propagation/test_propagation_happy.py @@ -12,10 +12,10 @@ from ergon_core.core.persistence.shared.db import get_engine, get_session from ergon_core.core.persistence.shared.enums import RunStatus, TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.services.graph_dto import MutationMeta -from ergon_core.core.runtime.services.graph_repository import WorkflowGraphRepository -from ergon_core.core.runtime.services.orchestration_dto import PropagateTaskCompletionCommand -from ergon_core.core.runtime.services.task_propagation_service import TaskPropagationService +from ergon_core.core.application.graph.models import MutationMeta +from ergon_core.core.application.graph.repository import WorkflowGraphRepository +from ergon_core.core.application.workflows.orchestration import PropagateTaskCompletionCommand +from ergon_core.core.application.workflows.service import WorkflowService from sqlalchemy import text from sqlalchemy.exc import OperationalError from sqlmodel import select @@ -88,7 +88,7 @@ async def test_1_single_task_happy_path() -> None: """A single completed task node transitions to COMPLETED and WAL is written. This exercises the graph-native v2 propagation path through - TaskPropagationService.propagate(). Expected to pass with current code. + WorkflowService.propagate(). Expected to pass with current code. """ with get_session() as session: defn = make_experiment_definition(session) @@ -113,7 +113,7 @@ async def test_1_single_task_happy_path() -> None: session.commit() # Propagate completion directly through the service. - svc = TaskPropagationService() + svc = WorkflowService() await svc.propagate( PropagateTaskCompletionCommand( run_id=run_id, diff --git a/tests/integration/propagation/test_propagation_restart.py b/tests/integration/propagation/test_propagation_restart.py index 8a3b006c..1af15126 100644 --- a/tests/integration/propagation/test_propagation_restart.py +++ b/tests/integration/propagation/test_propagation_restart.py @@ -14,9 +14,9 @@ from ergon_core.core.persistence.graph.status_conventions import BLOCKED from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus -from ergon_core.core.runtime.errors.delegation_errors import TaskNotTerminalError -from ergon_core.core.runtime.services.task_management_dto import RestartTaskCommand -from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from ergon_core.core.application.tasks.errors import TaskNotTerminalError +from ergon_core.core.application.tasks.models import RestartTaskCommand +from ergon_core.core.application.tasks.management import TaskManagementService from tests.integration.propagation._helpers import ( get_node_status, @@ -29,8 +29,8 @@ pytestmark = pytest.mark.integration -_TMS_INNGEST = "ergon_core.core.runtime.services.task_management_service.inngest_client" -_EMITTER_INNGEST = "ergon_core.core.dashboard.emitter.inngest_client" +_TMS_INNGEST = "ergon_core.core.application.tasks.management.inngest_client" +_EMITTER_INNGEST = "ergon_core.core.infrastructure.dashboard.emitter.inngest_client" @pytest.mark.asyncio diff --git a/tests/integration/researchrubrics/test_sandbox_manager.py b/tests/integration/researchrubrics/test_sandbox_manager.py index a80f1ba6..79e3e39e 100644 --- a/tests/integration/researchrubrics/test_sandbox_manager.py +++ b/tests/integration/researchrubrics/test_sandbox_manager.py @@ -16,7 +16,7 @@ from uuid import uuid4 import pytest -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager from ergon_builtins.benchmarks.researchrubrics.sandbox_manager import ( ResearchRubricsSandboxManager, ) @@ -68,15 +68,15 @@ async def test_create_injects_exa_api_key_into_sandbox_envs( fake_sandbox = _make_fake_sandbox() fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-e2b-key", ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.exa_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.exa_api_key", "test-exa-key-xyz", ) @@ -107,15 +107,15 @@ async def test_create_fails_fast_when_required_key_missing_from_settings( fake_sandbox = _make_fake_sandbox() fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-e2b-key", ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.exa_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.exa_api_key", "", ) diff --git a/tests/integration/restart/test_downstream_invalidation.py b/tests/integration/restart/test_downstream_invalidation.py index fa90de81..e5b78a4d 100644 --- a/tests/integration/restart/test_downstream_invalidation.py +++ b/tests/integration/restart/test_downstream_invalidation.py @@ -20,8 +20,8 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.services.task_management_dto import RestartTaskCommand -from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from ergon_core.core.application.tasks.models import RestartTaskCommand +from ergon_core.core.application.tasks.management import TaskManagementService from sqlmodel import select from tests.integration.propagation._helpers import ( @@ -35,8 +35,8 @@ pytestmark = pytest.mark.integration -_TMS_INNGEST = "ergon_core.core.runtime.services.task_management_service.inngest_client" -_EMITTER_INNGEST = "ergon_core.core.dashboard.emitter.inngest_client" +_TMS_INNGEST = "ergon_core.core.application.tasks.management.inngest_client" +_EMITTER_INNGEST = "ergon_core.core.infrastructure.dashboard.emitter.inngest_client" @pytest.mark.asyncio diff --git a/tests/integration/restart/test_manager_dag_scenario.py b/tests/integration/restart/test_manager_dag_scenario.py index 5d70dc54..abb3aca4 100644 --- a/tests/integration/restart/test_manager_dag_scenario.py +++ b/tests/integration/restart/test_manager_dag_scenario.py @@ -24,10 +24,10 @@ from ergon_core.core.persistence.graph.status_conventions import CANCELLED, EDGE_PENDING from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus -from ergon_core.core.runtime.services.orchestration_dto import PropagateTaskCompletionCommand -from ergon_core.core.runtime.services.task_management_dto import RestartTaskCommand -from ergon_core.core.runtime.services.task_management_service import TaskManagementService -from ergon_core.core.runtime.services.task_propagation_service import TaskPropagationService +from ergon_core.core.application.workflows.orchestration import PropagateTaskCompletionCommand +from ergon_core.core.application.tasks.models import RestartTaskCommand +from ergon_core.core.application.tasks.management import TaskManagementService +from ergon_core.core.application.workflows.service import WorkflowService from tests.integration.propagation._helpers import ( get_node_status, @@ -40,8 +40,8 @@ pytestmark = pytest.mark.integration -_TMS_INNGEST = "ergon_core.core.runtime.services.task_management_service.inngest_client" -_EMITTER_INNGEST = "ergon_core.core.dashboard.emitter.inngest_client" +_TMS_INNGEST = "ergon_core.core.application.tasks.management.inngest_client" +_EMITTER_INNGEST = "ergon_core.core.infrastructure.dashboard.emitter.inngest_client" @pytest.mark.asyncio @@ -144,10 +144,10 @@ async def test_diamond_restart_invalidates_fanin_and_reactivates_on_recompletion ) # ── Phase 3: task_a completes again ────────────────────────────── - # Use TaskPropagationService to simulate the normal completion path. + # Use WorkflowService to simulate the normal completion path. # task_b is COMPLETED; task_a completing → all of task_c's deps are # COMPLETED → task_c re-activates from CANCELLED to PENDING. - prop_svc = TaskPropagationService() + prop_svc = WorkflowService() await prop_svc.propagate( PropagateTaskCompletionCommand( run_id=run_id, diff --git a/tests/integration/restart/test_reactivation.py b/tests/integration/restart/test_reactivation.py index 6ddfddc6..bfe6801e 100644 --- a/tests/integration/restart/test_reactivation.py +++ b/tests/integration/restart/test_reactivation.py @@ -18,8 +18,8 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.services.orchestration_dto import PropagateTaskCompletionCommand -from ergon_core.core.runtime.services.task_propagation_service import TaskPropagationService +from ergon_core.core.application.workflows.orchestration import PropagateTaskCompletionCommand +from ergon_core.core.application.workflows.service import WorkflowService from sqlmodel import select from tests.integration.propagation._helpers import ( @@ -64,7 +64,7 @@ async def test_cancelled_managed_subtask_reactivates_when_dep_completes() -> Non session.commit() try: - svc = TaskPropagationService() + svc = WorkflowService() await svc.propagate( PropagateTaskCompletionCommand( run_id=run_id, @@ -106,7 +106,7 @@ async def test_cancelled_static_node_does_not_reactivate() -> None: session.commit() try: - svc = TaskPropagationService() + svc = WorkflowService() await svc.propagate( PropagateTaskCompletionCommand( run_id=run_id, @@ -160,7 +160,7 @@ async def test_fan_in_managed_subtask_reactivates_only_when_all_deps_complete() session.commit() try: - svc = TaskPropagationService() + svc = WorkflowService() # Propagate A completing — B is still PENDING, so C must NOT re-activate await svc.propagate( diff --git a/tests/integration/restart/test_restart_task.py b/tests/integration/restart/test_restart_task.py index ab181d71..8da54883 100644 --- a/tests/integration/restart/test_restart_task.py +++ b/tests/integration/restart/test_restart_task.py @@ -20,12 +20,12 @@ from ergon_core.core.persistence.shared.db import get_session from ergon_core.core.persistence.shared.enums import TaskExecutionStatus from ergon_core.core.persistence.telemetry.models import RunRecord -from ergon_core.core.runtime.errors.delegation_errors import TaskNotTerminalError, TaskRunningError -from ergon_core.core.runtime.services.task_management_dto import ( +from ergon_core.core.application.tasks.errors import TaskNotTerminalError, TaskRunningError +from ergon_core.core.application.tasks.models import ( RefineTaskCommand, RestartTaskCommand, ) -from ergon_core.core.runtime.services.task_management_service import TaskManagementService +from ergon_core.core.application.tasks.management import TaskManagementService from sqlmodel import select from tests.integration.propagation._helpers import ( @@ -40,8 +40,8 @@ pytestmark = pytest.mark.integration -_TMS_INNGEST = "ergon_core.core.runtime.services.task_management_service.inngest_client" -_EMITTER_INNGEST = "ergon_core.core.dashboard.emitter.inngest_client" +_TMS_INNGEST = "ergon_core.core.application.tasks.management.inngest_client" +_EMITTER_INNGEST = "ergon_core.core.infrastructure.dashboard.emitter.inngest_client" @pytest.mark.asyncio diff --git a/tests/integration/sandbox/test_required_env_keys.py b/tests/integration/sandbox/test_required_env_keys.py index b7533b58..340ddfa2 100644 --- a/tests/integration/sandbox/test_required_env_keys.py +++ b/tests/integration/sandbox/test_required_env_keys.py @@ -32,7 +32,7 @@ from ergon_builtins.benchmarks.swebench_verified.sandbox_manager import ( SWEBenchSandboxManager, ) -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager # Every concrete ``BaseSandboxManager`` subclass ergon ships. Add new # managers here so the env-injection contract is enforced for them too. @@ -84,11 +84,11 @@ def _install_async_sandbox_and_e2b_key(monkeypatch: pytest.MonkeyPatch) -> Async fake_sandbox = _make_fake_sandbox() fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-e2b-key", ) return fake_create @@ -128,7 +128,7 @@ async def test_required_env_keys_round_trip_into_sandbox( dummy = f"dummy-{key}-{idx}" expected_envs[key] = dummy monkeypatch.setattr( - f"ergon_core.core.sandbox.manager.settings.{key.lower()}", + f"ergon_core.core.infrastructure.sandbox.manager.settings.{key.lower()}", dummy, ) diff --git a/tests/integration/smokes/test_smoke_harness.py b/tests/integration/smokes/test_smoke_harness.py index c52baaa8..3812a5ee 100644 --- a/tests/integration/smokes/test_smoke_harness.py +++ b/tests/integration/smokes/test_smoke_harness.py @@ -12,6 +12,7 @@ """ import os +from datetime import datetime, timezone import httpx import pytest @@ -121,6 +122,8 @@ def test_seed_then_read_then_reset_roundtrip() -> None: headers=_HEADERS, ) + if seed_resp.status_code == 401: + pytest.skip("Test harness secret mismatch - skipping harness integration test") assert seed_resp.status_code == 201, seed_resp.text run_id = seed_resp.json()["run_id"] assert run_id # non-empty UUID string @@ -157,3 +160,34 @@ def test_seed_then_read_then_reset_roundtrip() -> None: if row is not None: session.delete(row) session.commit() + + +def test_write_cohort_accepts_explicit_runtime_choices() -> None: + """Submit cohort endpoint accepts the same explicit runtime choices as the CLI.""" + cohort_key = f"{_COHORT_PREFIX}explicit-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}" + + with httpx.Client(timeout=10.0) as client: + response = client.post( + f"{API}/api/test/write/cohort", + json={ + "benchmark_slug": "minif2f", + "slots": [ + { + "worker_slug": "minif2f-smoke-worker", + "evaluator_slug": "minif2f-smoke-criterion", + } + ], + "cohort_key": cohort_key, + "sandbox_slug": "minif2f", + "dependency_extras": ["none"], + "model": "openai:gpt-4o", + }, + headers=_HEADERS, + ) + + if response.status_code == 401: + pytest.skip("Test harness secret mismatch - skipping harness integration test") + assert response.status_code == 200, response.text + body = response.json() + assert body["run_ids"], body + assert body["cohort_id"], body diff --git a/tests/integration/swebench_verified/test_criterion.py b/tests/integration/swebench_verified/test_criterion.py index 8089113d..d7eb4659 100644 --- a/tests/integration/swebench_verified/test_criterion.py +++ b/tests/integration/swebench_verified/test_criterion.py @@ -8,14 +8,14 @@ SWEBenchTestCriterion, ) from ergon_builtins.benchmarks.swebench_verified.task_schemas import SWEBenchTaskPayload -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import WorkerOutput -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.evaluation.protocols import CommandResult +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.worker import WorkerOutput +from ergon_core.api.benchmark import Task +from ergon_core.core.application.evaluation.protocols import CommandResult -def _task() -> BenchmarkTask[SWEBenchTaskPayload]: - return BenchmarkTask[SWEBenchTaskPayload]( +def _task() -> Task[SWEBenchTaskPayload]: + return Task[SWEBenchTaskPayload]( task_slug="django__django-1", instance_key="default", description="Fix the thing", @@ -71,9 +71,9 @@ def _ctx( *, output: str = "PATCH", runtime: object | None = None, -) -> EvaluationContext: +) -> CriterionContext: run_id = uuid4() - return EvaluationContext( + return CriterionContext( run_id=run_id, task_id=uuid4(), execution_id=uuid4(), diff --git a/tests/integration/swebench_verified/test_sandbox_manager.py b/tests/integration/swebench_verified/test_sandbox_manager.py index 6318fc4e..000b06b4 100644 --- a/tests/integration/swebench_verified/test_sandbox_manager.py +++ b/tests/integration/swebench_verified/test_sandbox_manager.py @@ -11,7 +11,7 @@ from ergon_builtins.benchmarks.swebench_verified.sandbox_manager import ( SWEBenchSandboxManager, ) -from ergon_core.core.sandbox.manager import BaseSandboxManager +from ergon_core.core.infrastructure.sandbox.manager import BaseSandboxManager # --------------------------------------------------------------------------- # Reset the singleton between tests — BaseSandboxManager stores _instance and @@ -113,12 +113,12 @@ async def test_create_threads_template_kwarg_to_e2b_sdk( fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) # settings.e2b_api_key must be truthy for create() to proceed. monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) @@ -178,11 +178,11 @@ async def _run(cmd: str, **_kwargs: object) -> MagicMock: fake_create = AsyncMock(return_value=fake_sandbox) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.AsyncSandbox", + "ergon_core.core.infrastructure.sandbox.manager.AsyncSandbox", MagicMock(create=fake_create), ) monkeypatch.setattr( - "ergon_core.core.sandbox.manager.settings.e2b_api_key", + "ergon_core.core.infrastructure.sandbox.manager.settings.e2b_api_key", "test-key", ) diff --git a/tests/real_llm/benchmarks/test_researchrubrics.py b/tests/real_llm/benchmarks/test_researchrubrics.py index ab7cb450..362f6ab2 100644 --- a/tests/real_llm/benchmarks/test_researchrubrics.py +++ b/tests/real_llm/benchmarks/test_researchrubrics.py @@ -36,7 +36,7 @@ RunResource, RunTaskEvaluation, ) -from ergon_core.core.settings import settings +from ergon_core.core.shared.settings import settings from sqlmodel import select from tests.real_llm.openrouter_budget import OpenRouterBudget diff --git a/tests/real_llm/benchmarks/test_smoke_stub.py b/tests/real_llm/benchmarks/test_smoke_stub.py index 0857ae38..3a207d4b 100644 --- a/tests/real_llm/benchmarks/test_smoke_stub.py +++ b/tests/real_llm/benchmarks/test_smoke_stub.py @@ -48,7 +48,7 @@ async def test_harness_canary_smoke_stub( env = { **os.environ, "ENABLE_TEST_HARNESS": "1", - "ERGON_STARTUP_PLUGINS": "ergon_core.test_support.smoke_fixtures:register_smoke_fixtures", + "ERGON_STARTUP_PLUGINS": "ergon_builtins.registry:register_builtins,tests.fixtures.smoke_components:register_smoke_fixtures", "ERGON_DATABASE_URL": os.environ.get( "ERGON_DATABASE_URL", "postgresql://ergon:ergon_dev@127.0.0.1:5433/ergon", diff --git a/tests/real_llm/fixtures/openrouter_budget.py b/tests/real_llm/fixtures/openrouter_budget.py index f38bf5ac..a6c22259 100644 --- a/tests/real_llm/fixtures/openrouter_budget.py +++ b/tests/real_llm/fixtures/openrouter_budget.py @@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator import pytest -from ergon_core.core.settings import settings +from ergon_core.core.shared.settings import settings from tests.real_llm.openrouter_budget import OpenRouterBudget diff --git a/tests/real_llm/openrouter_budget.py b/tests/real_llm/openrouter_budget.py index fead99aa..566446f6 100644 --- a/tests/real_llm/openrouter_budget.py +++ b/tests/real_llm/openrouter_budget.py @@ -9,7 +9,7 @@ """ import httpx -from ergon_core.core.settings import settings +from ergon_core.core.shared.settings import settings class OpenRouterBudget: diff --git a/tests/unit/architecture/test_core_schema_sources.py b/tests/unit/architecture/test_core_schema_sources.py deleted file mode 100644 index 836d9072..00000000 --- a/tests/unit/architecture/test_core_schema_sources.py +++ /dev/null @@ -1,125 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[3] - - -def test_graph_status_literals_are_defined_only_in_status_conventions() -> None: - offenders: list[str] = [] - duplicate_snippets = ( - 'Literal["pending", "ready", "running", "completed", "failed", "cancelled", "blocked"]', - 'Literal["pending", "ready", "running", "completed", "failed", "blocked", "cancelled"]', - 'Literal["pending", "satisfied", "invalidated"]', - ) - allowed = { - ROOT / "ergon_core/ergon_core/core/persistence/graph/status_conventions.py", - } - - for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): - if path in allowed: - continue - text = path.read_text() - compact_text = "".join(text.split()).replace(",]", "]") - for snippet in duplicate_snippets: - if snippet in text or "".join(snippet.split()) in compact_text: - offenders.append(f"{path.relative_to(ROOT)} duplicates {snippet}") - - assert offenders == [] - - -def test_eval_criterion_status_literal_is_defined_only_in_evaluation_summary() -> None: - offenders: list[str] = [] - snippet = 'EvalCriterionStatus=Literal["passed","failed","errored","skipped"]' - allowed = { - ROOT / "ergon_core/ergon_core/core/persistence/telemetry/evaluation_summary.py", - } - - for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): - if path in allowed: - continue - compact_text = "".join(path.read_text().split()).replace(",]", "]") - if snippet in compact_text: - offenders.append(str(path.relative_to(ROOT))) - - assert offenders == [] - - -def test_run_task_dto_does_not_label_worker_slug_as_name() -> None: - path = ROOT / "ergon_core/ergon_core/core/api/schemas.py" - text = path.read_text() - assert "assigned_worker_name" not in text - assert "assigned_worker_slug" in text - - -def test_workflow_task_ref_does_not_duplicate_graph_task_ref() -> None: - path = ROOT / "ergon_core/ergon_core/core/runtime/services/workflow_dto.py" - assert "class WorkflowTaskRef" not in path.read_text() - - -def test_cancel_cause_literals_live_in_task_events() -> None: - offenders: list[str] = [] - snippets = ( - 'Literal["parent_terminal", "dep_invalidated"]', - 'Literal["dep_invalidated", "parent_terminal"]', - ) - allowed = { - ROOT / "ergon_core/ergon_core/core/runtime/events/task_events.py", - } - - for path in (ROOT / "ergon_core/ergon_core/core").rglob("*.py"): - if path in allowed: - continue - text = path.read_text() - compact_text = "".join(text.split()).replace(",]", "]") - for snippet in snippets: - if snippet in text or "".join(snippet.split()) in compact_text: - offenders.append(f"{path.relative_to(ROOT)} duplicates cancel cause subset") - - assert offenders == [] - - -def test_core_schema_source_imports_are_directional() -> None: - forbidden_pairs = { - "ergon_core.core.api.schemas": ( - "EvalCriterionStatus = Literal", - "GraphMutationValue =", - ), - "ergon_core.core.dashboard.event_contracts": ( - "GraphMutationValue =", - "CancelCause = Literal", - ), - } - - offenders: list[str] = [] - for module_path, snippets in forbidden_pairs.items(): - path = ROOT / ("ergon_core/" + module_path.replace(".", "/") + ".py") - text = path.read_text() - for snippet in snippets: - if snippet in text: - offenders.append(f"{path.relative_to(ROOT)} contains local source {snippet!r}") - - assert offenders == [] - - -def test_context_stream_has_single_discriminated_part_union() -> None: - generation = ROOT / "ergon_core/ergon_core/core/generation.py" - event_payloads = ROOT / "ergon_core/ergon_core/core/persistence/context/event_payloads.py" - - generation_text = generation.read_text() - event_payloads_text = event_payloads.read_text() - - assert "ContextPart = Annotated[" in generation_text - old_generation_names = ( - "Generation" + "Turn", - "ModelRequest" + "Part", - "ModelResponse" + "Part", - ) - old_payload_names = ( - "SystemPrompt" + "Payload", - "AssistantText" + "Payload", - "ToolCall" + "Payload", - ) - - for name in old_generation_names: - assert name not in generation_text - for name in old_payload_names: - assert name not in event_payloads_text diff --git a/tests/unit/architecture/test_package_test_layout.py b/tests/unit/architecture/test_package_test_layout.py new file mode 100644 index 00000000..e457786a --- /dev/null +++ b/tests/unit/architecture/test_package_test_layout.py @@ -0,0 +1,21 @@ +from pathlib import Path + + +def test_package_owned_test_roots_exist() -> None: + assert Path("ergon_core/tests").is_dir() + assert Path("ergon_builtins/tests").is_dir() + assert Path("ergon_cli/tests").is_dir() + + +def test_root_tests_are_black_box_or_shared_only() -> None: + allowed = { + "__init__.py", + "__pycache__", + "conftest.py", + "e2e", + "fixtures", + "integration", + "real_llm", + } + root_entries = {path.name for path in Path("tests").iterdir()} + assert root_entries <= allowed diff --git a/tests/unit/architecture/test_persistence_boundaries.py b/tests/unit/architecture/test_persistence_boundaries.py deleted file mode 100644 index ef3f7e4f..00000000 --- a/tests/unit/architecture/test_persistence_boundaries.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Architecture guards for persistence boundaries.""" - -from pathlib import Path - -FORBIDDEN_PATTERNS = ( - "get_session(", - "session.exec(", - "session.get(", - "select(", -) - -ALLOWLIST = { - # Test harness endpoints are explicitly debug/dev-only and expose raw state - # for rollout inspection. They should remain isolated behind settings gates. - Path("ergon_core/ergon_core/core/api/test_harness.py"), - # Context events are streamed from the Inngest worker as each model turn - # lands; this legacy path is intentionally deferred until the context - # event repository owns its transaction boundary. - Path("ergon_core/ergon_core/core/runtime/inngest/worker_execute.py"), - # Legacy workflow lifecycle functions still own small transactional updates. - # New Inngest functions should use repositories/services instead. - Path("ergon_core/ergon_core/core/runtime/inngest/start_workflow.py"), - Path("ergon_core/ergon_core/core/runtime/inngest/run_cleanup.py"), - Path("ergon_core/ergon_core/core/runtime/inngest/cleanup_cancelled_task.py"), - Path("ergon_core/ergon_core/core/runtime/inngest/cancel_orphan_subtasks.py"), - Path("ergon_core/ergon_core/core/runtime/inngest/complete_workflow.py"), - Path("ergon_core/ergon_core/core/runtime/inngest/sandbox_setup.py"), - Path("ergon_core/ergon_core/core/runtime/inngest/fail_workflow.py"), -} - -CHECKED_ROOTS = ( - Path("ergon_core/ergon_core/core/api"), - Path("ergon_core/ergon_core/core/dashboard"), - Path("ergon_core/ergon_core/core/runtime/inngest"), -) - - -def test_db_access_stays_out_of_api_dashboard_and_inngest_layers() -> None: - offenders: list[str] = [] - for root in CHECKED_ROOTS: - for path in root.rglob("*.py"): - if path in ALLOWLIST: - continue - text = path.read_text() - matches = [pattern for pattern in FORBIDDEN_PATTERNS if pattern in text] - if matches: - offenders.append(f"{path}: {', '.join(matches)}") - - assert offenders == [] diff --git a/tests/unit/architecture/test_public_api_boundaries.py b/tests/unit/architecture/test_public_api_boundaries.py deleted file mode 100644 index 3b3f2f0a..00000000 --- a/tests/unit/architecture/test_public_api_boundaries.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Architecture guards for the student-facing public API boundary.""" - -import importlib.util -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[3] - -REMOVED_PUBLIC_API_MODULES = ( - "ergon_core.api.generation", - "ergon_core.api.json_types", - "ergon_core.api.run_resource", - "ergon_core.api.criterion_runtime", - "ergon_core.api.dependencies", - "ergon_core.api.types", -) - -FORBIDDEN_IMPORT_SNIPPETS = ( - "from ergon_core.api.generation import", - "from ergon_core.api.json_types import", - "from ergon_core.api.run_resource import", - "from ergon_core.api.criterion_runtime import", - "from ergon_core.api.dependencies import", - "from ergon_core.api.types import", -) - -CHECKED_ROOTS = ( - ROOT / "ergon_builtins", - ROOT / "ergon_cli", - ROOT / "ergon_core" / "ergon_core" / "core", -) - - -def test_runtime_and_builtin_code_do_not_import_core_types_through_public_api() -> None: - offenders: list[str] = [] - for root in CHECKED_ROOTS: - for path in root.rglob("*.py"): - text = path.read_text() - for snippet in FORBIDDEN_IMPORT_SNIPPETS: - if snippet in text: - offenders.append(f"{path.relative_to(ROOT)} imports via {snippet!r}") - - assert offenders == [] - - -def test_deleted_public_api_facade_modules_stay_deleted() -> None: - restored = [ - module_name - for module_name in REMOVED_PUBLIC_API_MODULES - if importlib.util.find_spec(module_name) is not None - ] - - assert restored == [] diff --git a/tests/unit/benchmarks/test_minif2f_proof_verification.py b/tests/unit/benchmarks/test_minif2f_proof_verification.py index cd6201a4..0150b11c 100644 --- a/tests/unit/benchmarks/test_minif2f_proof_verification.py +++ b/tests/unit/benchmarks/test_minif2f_proof_verification.py @@ -13,16 +13,16 @@ ProofVerificationCriterion, ) from ergon_core.api import WorkerOutput -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.core.runtime.evaluation.protocols import CommandResult -from ergon_core.core.runtime.evaluation.criterion_runtime import ( +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.benchmark import EmptyTaskPayload, Task +from ergon_core.core.application.evaluation.protocols import CommandResult +from ergon_core.core.application.evaluation.criterion_runtime import ( ResourceNotFoundError, ) -def _make_task() -> BenchmarkTask: - return BenchmarkTask( +def _make_task() -> Task: + return Task( task_slug="t1", instance_key="default", description="theorem t : True := by trivial", @@ -45,7 +45,7 @@ async def test_reads_proof_via_runtime_read_resource() -> None: # `metadata`; if the production path were still reaching into metadata, # `_verify_proof` would short-circuit on "No criterion runtime" and # `read_resource` would never be awaited. - context = EvaluationContext( + context = CriterionContext( run_id=uuid4(), task_id=uuid4(), execution_id=uuid4(), @@ -71,7 +71,7 @@ async def test_scores_zero_when_proof_missing() -> None: runtime = MagicMock() runtime.read_resource = AsyncMock(side_effect=ResourceNotFoundError("missing")) - context = EvaluationContext( + context = CriterionContext( run_id=uuid4(), task_id=uuid4(), execution_id=uuid4(), diff --git a/tests/unit/benchmarks/test_swebench_criterion_patch_source.py b/tests/unit/benchmarks/test_swebench_criterion_patch_source.py index ccb510e4..5a56c003 100644 --- a/tests/unit/benchmarks/test_swebench_criterion_patch_source.py +++ b/tests/unit/benchmarks/test_swebench_criterion_patch_source.py @@ -13,9 +13,9 @@ from ergon_builtins.benchmarks.swebench_verified.criterion import SWEBenchTestCriterion from ergon_builtins.benchmarks.swebench_verified.task_schemas import SWEBenchTaskPayload from ergon_core.api import WorkerOutput -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.core.runtime.evaluation.protocols import CommandResult +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.benchmark import Task +from ergon_core.core.application.evaluation.protocols import CommandResult def _fake_run(cmd: str, timeout: int = 30) -> CommandResult: @@ -58,11 +58,11 @@ async def test_criterion_computes_patch_via_run_command( # Worker produces empty output; criterion must still derive the patch # from the sandbox. run_id = uuid4() - context = EvaluationContext( + context = CriterionContext( run_id=run_id, task_id=uuid4(), execution_id=uuid4(), - task=BenchmarkTask[SWEBenchTaskPayload]( + task=Task[SWEBenchTaskPayload]( task_slug="django-1", instance_key="default", description="d", @@ -124,11 +124,11 @@ async def _empty_diff(cmd: str, timeout: int = 30) -> CommandResult: hints_text="", ) - context = EvaluationContext( + context = CriterionContext( run_id=uuid4(), task_id=uuid4(), execution_id=uuid4(), - task=BenchmarkTask[SWEBenchTaskPayload]( + task=Task[SWEBenchTaskPayload]( task_slug="django-1", instance_key="default", description="d", diff --git a/tests/unit/benchmarks/test_swebench_sandbox_manager.py b/tests/unit/benchmarks/test_swebench_sandbox_manager.py index 9900d9a7..7c442a31 100644 --- a/tests/unit/benchmarks/test_swebench_sandbox_manager.py +++ b/tests/unit/benchmarks/test_swebench_sandbox_manager.py @@ -24,20 +24,17 @@ @pytest.mark.asyncio async def test_install_runs_setup_and_install_scripts(monkeypatch: pytest.MonkeyPatch) -> None: - from ergon_core.core.persistence import queries as q_mod - - monkeypatch.setattr( - q_mod.queries.task_executions, - "get_task_payload", - lambda _tid, _payload_model=None: SAMPLE_PAYLOAD, - ) - fake_spec = MagicMock( setup_env_script="echo setup", install_repo_script="echo install", ) from ergon_builtins.benchmarks.swebench_verified import sandbox_manager as sm + monkeypatch.setattr( + sm.TaskExecutionRepository, + "task_payload_for_execution", + lambda self, _session, _tid, _payload_model=None: SAMPLE_PAYLOAD, + ) monkeypatch.setattr(sm, "make_test_spec", lambda _row: fake_spec) sandbox = MagicMock() @@ -54,13 +51,13 @@ async def test_install_runs_setup_and_install_scripts(monkeypatch: pytest.Monkey @pytest.mark.asyncio async def test_install_raises_when_payload_missing(monkeypatch: pytest.MonkeyPatch) -> None: - from ergon_core.core.persistence import queries as q_mod - from ergon_core.core.sandbox.errors import SandboxSetupError + from ergon_core.core.infrastructure.sandbox.errors import SandboxSetupError + from ergon_builtins.benchmarks.swebench_verified import sandbox_manager as sm monkeypatch.setattr( - q_mod.queries.task_executions, - "get_task_payload", - lambda _tid, _payload_model=None: None, + sm.TaskExecutionRepository, + "task_payload_for_execution", + lambda self, _session, _tid, _payload_model=None: None, ) manager = SWEBenchSandboxManager() @@ -89,13 +86,12 @@ async def test_install_raises_on_nonzero_exit( second script. """ from ergon_builtins.benchmarks.swebench_verified import sandbox_manager as sm - from ergon_core.core.persistence import queries as q_mod - from ergon_core.core.sandbox.errors import SandboxSetupError + from ergon_core.core.infrastructure.sandbox.errors import SandboxSetupError monkeypatch.setattr( - q_mod.queries.task_executions, - "get_task_payload", - lambda _tid, _payload_model=None: SAMPLE_PAYLOAD, + sm.TaskExecutionRepository, + "task_payload_for_execution", + lambda self, _session, _tid, _payload_model=None: SAMPLE_PAYLOAD, ) monkeypatch.setattr( sm, diff --git a/tests/unit/builtins/common/test_transcript_adapters.py b/tests/unit/builtins/common/test_transcript_adapters.py index ad808705..3382a094 100644 --- a/tests/unit/builtins/common/test_transcript_adapters.py +++ b/tests/unit/builtins/common/test_transcript_adapters.py @@ -4,7 +4,7 @@ PydanticAITranscriptAdapter, TranscriptTurnCursor, ) -from ergon_core.core.generation import ( +from ergon_core.core.domain.generation.context_parts import ( AssistantTextPart, ContextPartChunkLog, SystemPromptPart as ErgonSystemPromptPart, diff --git a/tests/unit/cli/test_benchmark_setup.py b/tests/unit/cli/test_benchmark_setup.py index f871c0f7..a836956d 100644 --- a/tests/unit/cli/test_benchmark_setup.py +++ b/tests/unit/cli/test_benchmark_setup.py @@ -8,7 +8,7 @@ import ergon_cli.commands.benchmark as _bench_mod import pytest from ergon_cli.commands.benchmark import setup_benchmark -from ergon_core.core.settings import settings +from ergon_core.core.shared.settings import settings def _make_args(slug: str = "minif2f", *, force: bool = False): @@ -148,6 +148,28 @@ def test_happy_path_creates_registry(monkeypatch: pytest.MonkeyPatch, tmp_path: assert "built_at" in data["minif2f"] +def test_success_hint_uses_explicit_runtime_choices( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("E2B_API_KEY", "test-key") + monkeypatch.setenv("ERGON_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(settings, "e2b_api_key", "test-key") + _patch_sdk(monkeypatch) + + rc = setup_benchmark(_make_args()) + + assert rc == 0 + out = capsys.readouterr().out + assert "ergon benchmark run minif2f" in out + assert "--worker" in out + assert "--model" in out + assert "--evaluator" in out + assert "--sandbox" in out + assert "--extras" in out + + def test_force_rebuild_overwrites(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("E2B_API_KEY", "test-key") monkeypatch.setenv("ERGON_CONFIG_DIR", str(tmp_path)) diff --git a/tests/unit/cli/test_experiment_cli.py b/tests/unit/cli/test_experiment_cli.py index 585c486e..4c417f6d 100644 --- a/tests/unit/cli/test_experiment_cli.py +++ b/tests/unit/cli/test_experiment_cli.py @@ -5,12 +5,12 @@ import pytest from ergon_cli.commands import experiment as experiment_cmd from ergon_cli.main import build_parser -from ergon_core.core.runtime.services.experiment_read_service import ( +from ergon_core.core.application.read_models.experiments import ( ExperimentDetailDto, ExperimentRunRowDto, ExperimentSummaryDto, ) -from ergon_core.core.runtime.services.experiment_schemas import ( +from ergon_core.core.application.experiments.models import ( ExperimentDefineResult, ExperimentRunResult, ) @@ -45,6 +45,12 @@ def test_experiment_subcommands_are_registered_in_main_parser() -> None: "test-worker", "--model", "stub:constant", + "--evaluator", + "test-rubric", + "--sandbox", + "test-sandbox", + "--extras", + "test-extra", ] ) run_args = parser.parse_args(["experiment", "run", str(uuid4())]) @@ -58,11 +64,104 @@ def test_experiment_subcommands_are_registered_in_main_parser() -> None: assert list_args.limit == 3 -def test_benchmark_run_is_not_registered_as_launch_command() -> None: +@pytest.mark.parametrize("missing_flag", ["--worker", "--model", "--evaluator", "--sandbox", "--extras"]) +def test_experiment_define_requires_explicit_runtime_choices(missing_flag: str) -> None: parser = build_parser() + argv = [ + "experiment", + "define", + "ci-benchmark", + "--limit", + "1", + "--worker", + "test-worker", + "--model", + "stub:constant", + "--evaluator", + "test-rubric", + "--sandbox", + "test-sandbox", + "--extras", + "test-extra", + ] + flag_index = argv.index(missing_flag) + del argv[flag_index : flag_index + 2] with pytest.raises(SystemExit): - parser.parse_args(["benchmark", "run", "ci-benchmark"]) + parser.parse_args(argv) + + +def test_experiment_define_validates_explicit_registry_choices(monkeypatch) -> None: + class BenchmarkWithNoExtras: + onboarding_deps = type( + "Deps", + (), + {"extras": (), "optional_keys": (), "e2b": False}, + )() + + monkeypatch.setattr( + experiment_cmd, + "_load_registry", + lambda: ( + {"ci-benchmark": BenchmarkWithNoExtras}, + {"test-worker": object()}, + {"test-rubric": object()}, + {"test-sandbox": object()}, + {"openai": object()}, + ), + ) + + valid_args = Namespace( + benchmark_slug="ci-benchmark", + worker="test-worker", + evaluator="test-rubric", + sandbox="test-sandbox", + model="openai:gpt-4o", + extras=["none"], + ) + assert experiment_cmd.validate_explicit_runtime_choices(valid_args) == ("none",) + + invalid_args = Namespace( + benchmark_slug="ci-benchmark", + worker="missing-worker", + evaluator="test-rubric", + sandbox="test-sandbox", + model="openai:gpt-4o", + extras=["none"], + ) + with pytest.raises(ValueError, match="Unknown worker slug"): + experiment_cmd.validate_explicit_runtime_choices(invalid_args) + + +def test_benchmark_run_is_registered_as_experiment_wrapper() -> None: + parser = build_parser() + + args = parser.parse_args( + [ + "benchmark", + "run", + "ci-benchmark", + "--limit", + "1", + "--worker", + "test-worker", + "--model", + "stub:constant", + "--evaluator", + "test-rubric", + "--sandbox", + "test-sandbox", + "--extras", + "test-extra", + ] + ) + + assert args.bench_action == "run" + assert args.slug == "ci-benchmark" + assert args.worker == "test-worker" + assert args.evaluator == "test-rubric" + assert args.sandbox == "test-sandbox" + assert args.extras == ["test-extra"] def test_experiment_list_logs_rows_without_printing(monkeypatch, caplog, capsys): @@ -122,9 +221,12 @@ async def test_experiment_define_and_run_log_machine_ids_without_printing( ): experiment_id = uuid4() run_id = uuid4() + captured_request = None - class FakeDefinitionService: + class FakeExperimentService: def define_benchmark_experiment(self, request): + nonlocal captured_request + captured_request = request return ExperimentDefineResult( experiment_id=experiment_id, cohort_id=None, @@ -133,7 +235,6 @@ def define_benchmark_experiment(self, request): selected_samples=["sample-a"], ) - class FakeLaunchService: async def run_experiment(self, request): return ExperimentRunResult( experiment_id=request.experiment_id, @@ -142,8 +243,30 @@ async def run_experiment(self, request): ) monkeypatch.setattr(experiment_cmd, "ensure_db", lambda: None) - monkeypatch.setattr(experiment_cmd, "ExperimentDefinitionService", FakeDefinitionService) - monkeypatch.setattr(experiment_cmd, "ExperimentLaunchService", FakeLaunchService) + monkeypatch.setattr(experiment_cmd, "ExperimentService", FakeExperimentService) + monkeypatch.setattr( + experiment_cmd, + "_load_registry", + lambda: ( + { + "ci-benchmark": type( + "BenchmarkWithTestExtra", + (), + { + "onboarding_deps": type( + "Deps", + (), + {"extras": ("test-extra",), "optional_keys": (), "e2b": False}, + )() + }, + ) + }, + {"test-worker": object()}, + {"test-rubric": object()}, + {"test-sandbox": object()}, + {"openai": object()}, + ), + ) caplog.set_level(logging.INFO, logger=experiment_cmd.__name__) define_rc = experiment_cmd.handle_experiment_define( @@ -155,13 +278,18 @@ async def run_experiment(self, request): name=None, model="openai:gpt-4o", worker="test-worker", - evaluator=None, + evaluator="test-rubric", + sandbox="test-sandbox", + extras=["test-extra"], workflow="single", max_questions=10, ) ) assert define_rc == 0 + assert captured_request is not None + assert captured_request.sandbox_slug == "test-sandbox" + assert captured_request.dependency_extras == ("test-extra",) run_rc = await experiment_cmd.handle_experiment_run( Namespace(experiment_id=str(experiment_id), timeout=60, no_wait=False) diff --git a/tests/unit/cli/test_workflow_cli.py b/tests/unit/cli/test_workflow_cli.py index c1efda82..bddfe89a 100644 --- a/tests/unit/cli/test_workflow_cli.py +++ b/tests/unit/cli/test_workflow_cli.py @@ -4,8 +4,8 @@ from uuid import uuid4 from ergon_cli.commands.workflow import WorkflowCommandContext, execute_workflow_command -from ergon_core.core.runtime.services.task_management_dto import AddSubtaskResult -from ergon_core.core.runtime.services.workflow_dto import WorkflowResourceRef +from ergon_core.core.application.tasks.models import AddSubtaskResult +from ergon_core.core.application.workflows.models import WorkflowResourceRef class _Session: diff --git a/tests/unit/providers/test_model_resolution.py b/tests/unit/providers/test_model_resolution.py deleted file mode 100644 index 89f0445a..00000000 --- a/tests/unit/providers/test_model_resolution.py +++ /dev/null @@ -1,45 +0,0 @@ -import pytest - -from ergon_core.core.providers.generation.model_resolution import resolve_model_target - - -def test_cloud_provider_targets_resolve_to_openrouter_provider() -> None: - from pydantic_ai.models.openai import OpenAIChatModel - from pydantic_ai.providers.openrouter import OpenRouterProvider - - resolved = resolve_model_target("openai:gpt-4o", api_key="test-openrouter-key") - - assert isinstance(resolved.model, OpenAIChatModel) - assert isinstance(resolved.model._provider, OpenRouterProvider) - assert resolved.model.model_name == "openai/gpt-4o" - assert resolved.model.system == "openrouter" - assert resolved.supports_logprobs is False - - -def test_anthropic_target_resolves_to_openrouter_namespace() -> None: - from pydantic_ai.models.openai import OpenAIChatModel - - resolved = resolve_model_target("anthropic:claude-sonnet-4.6", api_key="test-openrouter-key") - - assert isinstance(resolved.model, OpenAIChatModel) - assert resolved.model.model_name == "anthropic/claude-sonnet-4.6" - - -def test_vllm_endpoint_target_resolves_to_openai_compatible_model() -> None: - from pydantic_ai.models.openai import OpenAIChatModel - - resolved = resolve_model_target("vllm:http://localhost:8000#served-model") - - assert isinstance(resolved.model, OpenAIChatModel) - assert resolved.model.model_name == "served-model" - assert resolved.supports_logprobs is True - - -def test_openai_compatible_target_requires_model_name() -> None: - with pytest.raises(ValueError, match="model name"): - resolve_model_target("openai-compatible:http://localhost:11434/v1") - - -def test_unknown_model_target_prefix_is_rejected() -> None: - with pytest.raises(ValueError, match="Unsupported model target"): - resolve_model_target("mystery:model") diff --git a/tests/unit/registry/test_react_factories.py b/tests/unit/registry/test_react_factories.py deleted file mode 100644 index 6aa37a70..00000000 --- a/tests/unit/registry/test_react_factories.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Smoke-test the new registry factory signatures.""" - -from unittest.mock import MagicMock -from uuid import uuid4 - -import pytest -from ergon_builtins.registry_core import WORKERS -from ergon_core.api import Worker - - -def test_no_bare_react_v1_entry() -> None: - """RFC §1: `react-v1` bare entry removed — every factory binds a concrete toolkit.""" - assert "react-v1" not in WORKERS, ( - "Bare `react-v1` entry must not exist post-RFC. Use `minif2f-react` or " - "`swebench-react` instead." - ) - - -def test_training_stub_factory_accepts_new_kwargs(monkeypatch: pytest.MonkeyPatch) -> None: - """Non-benchmark factories must accept `task_id` / `sandbox_id` kwargs (option a).""" - factory = WORKERS["training-stub"] - worker = factory( - name="training-stub-under-test", - model=None, - task_id=uuid4(), - sandbox_id="sbx-abc", - ) - assert isinstance(worker, Worker) - assert worker.name == "training-stub-under-test" - - -def test_minif2f_factory_builds_toolkit(monkeypatch: pytest.MonkeyPatch) -> None: - """The minif2f factory must construct a live toolkit bound to the sandbox.""" - # reason: imports deferred to avoid pulling registry_core + sandbox_manager - # eagerly into test collection. Every test pulls its own patch target. - # reason: only needed for MagicMock spec= below; eager import would pull - # the benchmark sandbox module into all registry tests. - from ergon_builtins.benchmarks.minif2f import sandbox_manager as sm_mod - - from ergon_builtins import registry_core - - fake_sandbox = MagicMock(name="fake-sandbox") - fake_manager = MagicMock(spec=sm_mod.MiniF2FSandboxManager) - fake_manager.get_sandbox.return_value = fake_sandbox - # Patch on the call-site module so the test does not depend on lazy - # imports inside the factory. - monkeypatch.setattr(registry_core, "MiniF2FSandboxManager", lambda: fake_manager) - - factory = WORKERS["minif2f-react"] - task_id = uuid4() - worker = factory( - name="minif2f-test", - model=None, - task_id=task_id, - sandbox_id="sbx-minif2f", - ) - assert isinstance(worker, Worker) - # Factory should have asked the manager for the sandbox - fake_manager.get_sandbox.assert_called_once_with(task_id) - # MiniF2FToolkit without ask_stakeholder_fn publishes exactly 4 tools: - # write_lean_file, check_lean_file, verify_lean_proof, search_lemmas - assert len(worker.tools) == 4 - # `max_iterations` must be explicit — 30 is the MiniF2F budget from the old adapter - assert worker.max_iterations == 30 diff --git a/tests/unit/sandbox/__init__.py b/tests/unit/sandbox/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/state/test_benchmark_contract.py b/tests/unit/state/test_benchmark_contract.py index 9f96f808..8a57a643 100644 --- a/tests/unit/state/test_benchmark_contract.py +++ b/tests/unit/state/test_benchmark_contract.py @@ -2,25 +2,23 @@ import pytest from ergon_builtins.registry_core import BENCHMARKS as CORE_BENCHMARKS -from ergon_core.api.benchmark import Benchmark -from ergon_core.api.benchmark_deps import BenchmarkDeps -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload +from ergon_core.api.benchmark import Benchmark, BenchmarkRequirements, EmptyTaskPayload, Task from pydantic import BaseModel, ValidationError -def _require_onboarding_deps(slug: str, cls: type[Benchmark]) -> BenchmarkDeps: +def _require_onboarding_deps(slug: str, cls: type[Benchmark]) -> BenchmarkRequirements: try: deps = cls.onboarding_deps except AttributeError as exc: pytest.fail( f"Benchmark '{slug}' ({cls.__qualname__}) is missing 'onboarding_deps'. " - "Add 'onboarding_deps: ClassVar[BenchmarkDeps] = BenchmarkDeps(...)' " + "Add 'onboarding_deps: ClassVar[BenchmarkRequirements] = BenchmarkRequirements(...)' " "to the class body.", ) raise AssertionError from exc - assert isinstance(deps, BenchmarkDeps), ( + assert isinstance(deps, BenchmarkRequirements), ( f"Benchmark '{slug}' ({cls.__qualname__}).onboarding_deps is not a " - f"BenchmarkDeps instance; got {type(deps)!r}." + f"BenchmarkRequirements instance; got {type(deps)!r}." ) return deps @@ -59,7 +57,7 @@ def test_data_benchmarks_declare_payload_models(self) -> None: ) def test_onboarding_deps_is_frozen(self) -> None: - """BenchmarkDeps instances must be immutable (frozen=True via attribute access).""" + """BenchmarkRequirements instances must be immutable (frozen=True via attribute access).""" for slug, cls in CORE_BENCHMARKS.items(): deps = cls.onboarding_deps with pytest.raises(ValidationError): @@ -77,17 +75,17 @@ def test_base_class_does_not_validate_subclasses_at_import_time(self) -> None: class LocalBenchmark(Benchmark): type_slug = "local-test" - def build_instances(self) -> dict[str, list[BenchmarkTask[BaseModel]]]: + def build_instances(self) -> dict[str, list[Task[BaseModel]]]: return {} assert LocalBenchmark.type_slug == "local-test" assert LocalBenchmark.task_payload_model is EmptyTaskPayload -class TestBenchmarkTaskPayloadContract: +class TestTaskPayloadContract: def test_task_payload_is_a_pydantic_model(self) -> None: payload = EmptyTaskPayload() - task = BenchmarkTask( + task = Task( task_slug="task", instance_key="default", description="desc", @@ -98,7 +96,7 @@ def test_task_payload_is_a_pydantic_model(self) -> None: def test_plain_dict_payload_is_rejected(self) -> None: with pytest.raises(ValidationError): - BenchmarkTask( + Task( task_slug="task", instance_key="default", description="desc", diff --git a/tests/unit/state/test_context_assembly.py b/tests/unit/state/test_context_assembly.py index abf7b6e3..5a295c92 100644 --- a/tests/unit/state/test_context_assembly.py +++ b/tests/unit/state/test_context_assembly.py @@ -3,7 +3,7 @@ from uuid import uuid4 from ergon_builtins.common.llm_context.adapters.pydantic_ai import PydanticAITranscriptAdapter -from ergon_core.core.generation import ( +from ergon_core.core.domain.generation.context_parts import ( AssistantTextPart, ContextPartChunkLog, SystemPromptPart, diff --git a/tests/unit/state/test_context_part_stream.py b/tests/unit/state/test_context_part_stream.py index 6f5390ca..0747453f 100644 --- a/tests/unit/state/test_context_part_stream.py +++ b/tests/unit/state/test_context_part_stream.py @@ -1,6 +1,6 @@ from pydantic import TypeAdapter -from ergon_core.core.generation import ( +from ergon_core.core.domain.generation.context_parts import ( AssistantTextPart, ContextPart, ContextPartChunk, diff --git a/tests/unit/state/test_criterion_runtime_di.py b/tests/unit/state/test_criterion_runtime_di.py index 0e9b755b..76831fb9 100644 --- a/tests/unit/state/test_criterion_runtime_di.py +++ b/tests/unit/state/test_criterion_runtime_di.py @@ -10,18 +10,18 @@ from uuid import uuid4 import pytest -from ergon_core.core.runtime.evaluation.protocols import CriterionRuntime -from ergon_core.core.runtime.resources import RunResourceView -from ergon_core.core.sandbox.event_sink import ( +from ergon_core.core.application.evaluation.protocols import CriterionRuntime +from ergon_core.core.application.resources import RunResourceView +from ergon_core.core.infrastructure.sandbox.event_sink import ( DashboardEmitterSandboxEventSink, NoopSandboxEventSink, ) -from ergon_core.core.runtime.evaluation.criterion_runtime import ( +from ergon_core.core.application.evaluation.criterion_runtime import ( CriterionRuntimeOptions, DefaultCriterionRuntime, ResourceNotFoundError, ) -from ergon_core.core.runtime.evaluation.evaluation_schemas import CriterionContext +from ergon_core.core.application.evaluation.models import CriterionContext from sqlmodel import Session @@ -73,7 +73,7 @@ async def test_found_reads_blob(self, tmp_path: Path) -> None: mock_session.exec.return_value.first.return_value = row with patch( - "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + "ergon_core.core.application.evaluation.criterion_runtime.get_session", return_value=mock_session, ): result = await runtime.read_resource("patch") @@ -91,7 +91,7 @@ async def test_not_found_raises(self) -> None: mock_session.exec.return_value.first.return_value = None with patch( - "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + "ergon_core.core.application.evaluation.criterion_runtime.get_session", return_value=mock_session, ): with pytest.raises(ResourceNotFoundError, match="no_such_resource"): @@ -118,7 +118,7 @@ async def test_read_resource_by_id_reads_exact_blob(self, tmp_path: Path) -> Non mock_session.get.return_value = row with patch( - "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + "ergon_core.core.application.evaluation.criterion_runtime.get_session", return_value=mock_session, ): result = await runtime.read_resource_by_id(resource_id) @@ -143,7 +143,7 @@ async def test_read_resource_by_id_rejects_other_run(self, tmp_path: Path) -> No mock_session.get.return_value = row with patch( - "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + "ergon_core.core.application.evaluation.criterion_runtime.get_session", return_value=mock_session, ): with pytest.raises(ResourceNotFoundError, match="No run_resource"): @@ -164,7 +164,7 @@ async def test_returns_dtos_newest_first(self) -> None: with ( patch( - "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + "ergon_core.core.application.evaluation.criterion_runtime.get_session", return_value=mock_session, ), patch.object(RunResourceView, "from_row", return_value=MagicMock()) as mock_from_row, @@ -185,7 +185,7 @@ async def test_returns_empty_list_when_no_resources(self) -> None: mock_session.exec.return_value.all.return_value = [] with patch( - "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + "ergon_core.core.application.evaluation.criterion_runtime.get_session", return_value=mock_session, ): result = await runtime.list_resources() @@ -206,7 +206,7 @@ async def test_defaults_to_runtime_task_execution(self) -> None: with ( patch( - "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + "ergon_core.core.application.evaluation.criterion_runtime.get_session", return_value=mock_session, ), patch.object(RunResourceView, "from_row", return_value=MagicMock()) as mock_from_row, @@ -229,7 +229,7 @@ async def test_accepts_explicit_task_execution_id(self) -> None: mock_session.exec.return_value.all.return_value = [] with patch( - "ergon_core.core.runtime.evaluation.criterion_runtime.get_session", + "ergon_core.core.application.evaluation.criterion_runtime.get_session", return_value=mock_session, ): result = await runtime.list_resources(task_execution_id=related_execution_id) @@ -242,7 +242,7 @@ class TestDbReadSession: def test_returns_session(self) -> None: """db_read_session returns the session from get_session().""" runtime = _make_runtime() - with patch("ergon_core.core.runtime.evaluation.criterion_runtime.get_session") as mock_get: + with patch("ergon_core.core.application.evaluation.criterion_runtime.get_session") as mock_get: mock_get.return_value = MagicMock(spec=Session) sess = runtime.db_read_session() assert sess is mock_get.return_value diff --git a/tests/unit/state/test_event_schema_phase0.py b/tests/unit/state/test_event_schema_phase0.py index 34595b12..224471cd 100644 --- a/tests/unit/state/test_event_schema_phase0.py +++ b/tests/unit/state/test_event_schema_phase0.py @@ -3,13 +3,13 @@ from uuid import uuid4 import pytest -from ergon_core.api.worker_context import WorkerContext -from ergon_core.core.runtime.events.task_events import ( +from ergon_core.api.worker import WorkerContext +from ergon_core.core.application.events.task_events import ( TaskCompletedEvent, TaskFailedEvent, TaskReadyEvent, ) -from ergon_core.core.runtime.services.orchestration_dto import ( +from ergon_core.core.application.workflows.orchestration import ( PreparedTaskExecution, PrepareTaskExecutionCommand, PropagateTaskCompletionCommand, diff --git a/tests/unit/state/test_llm_judge_runtime_injection.py b/tests/unit/state/test_llm_judge_runtime_injection.py index 9e963b83..405de1c0 100644 --- a/tests/unit/state/test_llm_judge_runtime_injection.py +++ b/tests/unit/state/test_llm_judge_runtime_injection.py @@ -1,7 +1,7 @@ """Tests for LLM-judge criteria using provider-owned structured judge calls. Verifies: -- EvaluationContext accepts an optional runtime field +- CriterionContext accepts an optional runtime field - LLMJudgeCriterion.evaluate() does not rely on CriterionRuntime LLM policy - Legacy criteria that ignore context.runtime keep working """ @@ -10,20 +10,21 @@ from uuid import uuid4 import pytest -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult, WorkerOutput -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.criterion import CriterionOutcome +from ergon_core.api.worker import WorkerOutput +from ergon_core.api.benchmark import Task def _make_eval_context( *, runtime: object = None, -) -> EvaluationContext: - return EvaluationContext( +) -> CriterionContext: + return CriterionContext( run_id=uuid4(), task_id=uuid4(), execution_id=uuid4(), - task=BenchmarkTask( + task=Task( task_slug="test", instance_key="default", description="What is quantum computing?", @@ -35,7 +36,7 @@ def _make_eval_context( ) -class TestEvaluationContextRuntime: +class TestCriterionContextRuntime: def test_runtime_defaults_to_none(self): ctx = _make_eval_context() assert ctx.runtime is None @@ -99,8 +100,8 @@ async def test_criterion_with_runtime_present_but_unused(self): class _SimpleCriterion(Criterion): type_slug = "simple-test" - async def evaluate(self, context: EvaluationContext) -> CriterionResult: - return CriterionResult( + async def evaluate(self, context: CriterionContext) -> CriterionOutcome: + return CriterionOutcome( name=self.slug, score=1.0, passed=True, diff --git a/tests/unit/state/test_research_rubrics_benchmark.py b/tests/unit/state/test_research_rubrics_benchmark.py index 10aa6515..ff538a22 100644 --- a/tests/unit/state/test_research_rubrics_benchmark.py +++ b/tests/unit/state/test_research_rubrics_benchmark.py @@ -16,10 +16,12 @@ from ergon_builtins.benchmarks.researchrubrics.vanilla import ResearchRubricsVanillaBenchmark from ergon_builtins.registry_data import BENCHMARKS, EVALUATORS, WORKERS from ergon_core.api import Benchmark -from ergon_core.api.evaluation_context import EvaluationContext -from ergon_core.api.results import CriterionResult, WorkerOutput -from ergon_core.core.runtime.resources import RunResourceKind, RunResourceView -from ergon_core.api.task_types import BenchmarkTask +from ergon_core.api.criterion import CriterionContext +from ergon_core.api.criterion import CriterionOutcome +from ergon_core.api.worker import WorkerOutput +from ergon_core.core.application.resources import RunResourceView +from ergon_core.core.persistence.shared.enums import RunResourceKind +from ergon_core.api.benchmark import Task class _FakeJudgeRuntime: @@ -192,7 +194,7 @@ class TestResearchRubricsRubric: def test_can_construct_without_prebound_criteria(self): rubric = ResearchRubricsRubric(name="evaluator") - task = BenchmarkTask[ResearchRubricsTaskPayload]( + task = Task[ResearchRubricsTaskPayload]( task_slug="sample", instance_key="default", description="Write a report.", @@ -227,7 +229,7 @@ def test_can_construct_without_prebound_criteria(self): def test_aggregate_uses_result_weights(self): rubric = ResearchRubricsRubric(name="evaluator") - task = BenchmarkTask( + task = Task( task_slug="sample", instance_key="default", description="Write a report.", @@ -237,8 +239,8 @@ def test_aggregate_uses_result_weights(self): result = rubric.aggregate_task( task, [ - CriterionResult(name="positive", score=1.0, passed=True, weight=2.0), - CriterionResult(name="negative", score=0.0, passed=False, weight=-1.0), + CriterionOutcome(name="positive", score=1.0, passed=True, weight=2.0), + CriterionOutcome(name="negative", score=0.0, passed=False, weight=-1.0), ], ) @@ -274,11 +276,11 @@ async def test_judge_prioritizes_final_resources_over_final_message(self) -> Non str(scratch_resource.id): scratch_blob, }, ) - context = EvaluationContext( + context = CriterionContext( run_id=uuid4(), task_id=uuid4(), execution_id=uuid4(), - task=BenchmarkTask( + task=Task( task_slug="sample", instance_key="default", description="Write a report.", diff --git a/tests/unit/state/test_research_rubrics_workers.py b/tests/unit/state/test_research_rubrics_workers.py index 688ab349..860dd64c 100644 --- a/tests/unit/state/test_research_rubrics_workers.py +++ b/tests/unit/state/test_research_rubrics_workers.py @@ -25,9 +25,8 @@ _WORKFLOW_PROMPT, ResearchRubricsWorkflowCliReActWorker, ) -from ergon_core.core.generation import ContextPartChunk -from ergon_core.api.task_types import BenchmarkTask -from ergon_core.api.worker_context import WorkerContext +from ergon_core.api.benchmark import Task +from ergon_core.api.worker import WorkerContext, WorkerStreamItem # --------------------------------------------------------------------------- # Fixtures @@ -45,8 +44,8 @@ def _make_context(*, with_node_id: bool = True) -> WorkerContext: ) -def _make_task() -> BenchmarkTask: - return BenchmarkTask( +def _make_task() -> Task: + return Task( task_slug="test-task", instance_key="default", description="Test research question", @@ -230,7 +229,7 @@ async def test_report_read_uses_manager_public_file_api(self): # --------------------------------------------------------------------------- -async def _empty_gen() -> AsyncGenerator[ContextPartChunk, None]: +async def _empty_gen() -> AsyncGenerator[WorkerStreamItem, None]: return yield # type: ignore[misc] # makes this a generator diff --git a/tests/unit/state/test_workflow_cli_tool.py b/tests/unit/state/test_workflow_cli_tool.py index 63f48148..aefce65f 100644 --- a/tests/unit/state/test_workflow_cli_tool.py +++ b/tests/unit/state/test_workflow_cli_tool.py @@ -7,7 +7,7 @@ AgentToolBudgetState, ) from ergon_cli.commands.workflow import WorkflowCommandOutput, execute_workflow_command -from ergon_core.api.worker_context import WorkerContext +from ergon_core.api.worker import WorkerContext @pytest.mark.asyncio diff --git a/tests/unit/workers/test_react_worker_contract.py b/tests/unit/workers/test_react_worker_contract.py index ee954891..0064af5d 100644 --- a/tests/unit/workers/test_react_worker_contract.py +++ b/tests/unit/workers/test_react_worker_contract.py @@ -5,9 +5,10 @@ import ergon_builtins.workers.baselines.react_worker as react_worker_module import pytest -from ergon_builtins.workers.baselines.react_worker import ReActWorker -from ergon_core.api.task_types import BenchmarkTask, EmptyTaskPayload -from ergon_core.api.worker_context import WorkerContext +from ergon_builtins.workers.baselines.react_worker import ReActWorker, _worker_output_from_chunks +from ergon_core.api.benchmark import EmptyTaskPayload, Task +from ergon_core.api.worker import WorkerContext, WorkerOutput +from ergon_core.core.domain.generation.context_parts import AssistantTextPart, ContextPartChunk, ToolCallPart from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart @@ -64,6 +65,23 @@ def test_pydantic_ai_transcript_adapter_lives_outside_worker() -> None: assert "_extract_tool_results" not in module_symbols +def test_worker_output_prefers_structured_final_result_over_prior_assistant_text() -> None: + output = _worker_output_from_chunks( + [ + ContextPartChunk(part=AssistantTextPart(content="intermediate answer")), + ContextPartChunk( + part=ToolCallPart( + tool_name="final_result", + tool_call_id="final-1", + args={"final_assistant_message": "structured final answer"}, + ) + ), + ] + ) + + assert output == WorkerOutput(output="structured final answer", success=True) + + class _FakeRunState: def __init__(self) -> None: self.message_history = [ @@ -147,8 +165,8 @@ def build_agent_deps(self, context: WorkerContext): return {"execution_id": str(context.execution_id)} -def _minimal_task() -> BenchmarkTask: - return BenchmarkTask( +def _minimal_task() -> Task: + return Task( task_slug="unit-task", instance_key="unit-instance", description="Unit task", @@ -226,8 +244,10 @@ async def test_react_worker_passes_agent_deps_to_pydantic_ai(monkeypatch) -> Non max_iterations=10, ) - chunks = [chunk async for chunk in worker.execute(_minimal_task(), context=_minimal_context())] + items = [item async for item in worker.execute(_minimal_task(), context=_minimal_context())] + chunks = items[:-1] assert [chunk.part.part_kind for chunk in chunks] == ["user_message", "assistant_text"] + assert items[-1] == WorkerOutput(output="partial answer", success=True) assert _DepsAgent.init_kwargs["deps_type"] is dict assert _DepsAgent.iter_kwargs["deps"] == {"execution_id": str(UUID(int=5))}