diff --git a/.github/workflows/relay-evals.yml b/.github/workflows/relay-evals.yml new file mode 100644 index 000000000..5d1b61bdd --- /dev/null +++ b/.github/workflows/relay-evals.yml @@ -0,0 +1,75 @@ +name: Relay Evals + +on: + pull_request: + paths: + - ".github/workflows/relay-evals.yml" + - "AGENTS.md" + - "README.md" + - "evals/**" + - "packages/**" + - "scripts/evals/**" + - "package.json" + - "package-lock.json" + push: + branches: + - main + paths: + - ".github/workflows/relay-evals.yml" + - "AGENTS.md" + - "README.md" + - "evals/**" + - "packages/**" + - "scripts/evals/**" + - "package.json" + - "package-lock.json" + workflow_dispatch: + +concurrency: + group: relay-evals-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + NODE_VERSION: "22" + NPM_CONFIG_FUND: "false" + +jobs: + evals: + name: Offline evals + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build SDK + run: npm run build:sdk + + - name: Run offline evals + run: npm run evals:offline + + - name: Summarize evals + if: always() + run: node scripts/evals/ci-summary.mjs + + - name: Upload eval artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: relay-eval-run + path: .relay/evals/runs/ + retention-days: 14 + if-no-files-found: ignore diff --git a/.trajectories/completed/2026-06/traj_o61z0ze6kvla/summary.md b/.trajectories/completed/2026-06/traj_o61z0ze6kvla/summary.md new file mode 100644 index 000000000..0fb93f7c5 --- /dev/null +++ b/.trajectories/completed/2026-06/traj_o61z0ze6kvla/summary.md @@ -0,0 +1,31 @@ +# Trajectory: Review and fix PR #1092 + +> **Status:** ✅ Completed +> **Confidence:** 70% +> **Started:** June 11, 2026 at 08:29 AM +> **Completed:** June 11, 2026 at 08:30 AM + +--- + +## Summary + +Fixed eval parser, runner, summary, executor, and relay-check issues for PR #1092; scoped verification passed, full verification blocked by incomplete dependency install and GitHub mergeability is dirty. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Kept fixes scoped to relay eval harness +- **Chose:** Kept fixes scoped to relay eval harness +- **Reasoning:** Validated current PR comments and changed only PR eval scripts/checks; full repo verification is blocked by killed npm ci leaving missing dependencies. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Kept fixes scoped to relay eval harness: Kept fixes scoped to relay eval harness diff --git a/.trajectories/completed/2026-06/traj_o61z0ze6kvla/trajectory.json b/.trajectories/completed/2026-06/traj_o61z0ze6kvla/trajectory.json new file mode 100644 index 000000000..e50e7b38d --- /dev/null +++ b/.trajectories/completed/2026-06/traj_o61z0ze6kvla/trajectory.json @@ -0,0 +1,53 @@ +{ + "id": "traj_o61z0ze6kvla", + "version": 1, + "task": { + "title": "Review and fix PR #1092" + }, + "status": "completed", + "startedAt": "2026-06-11T08:29:58.788Z", + "completedAt": "2026-06-11T08:30:00.119Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-06-11T08:29:59.481Z" + } + ], + "chapters": [ + { + "id": "chap_f292s4revkwp", + "title": "Work", + "agentName": "default", + "startedAt": "2026-06-11T08:29:59.481Z", + "endedAt": "2026-06-11T08:30:00.119Z", + "events": [ + { + "ts": 1781166599482, + "type": "decision", + "content": "Kept fixes scoped to relay eval harness: Kept fixes scoped to relay eval harness", + "raw": { + "question": "Kept fixes scoped to relay eval harness", + "chosen": "Kept fixes scoped to relay eval harness", + "alternatives": [], + "reasoning": "Validated current PR comments and changed only PR eval scripts/checks; full repo verification is blocked by killed npm ci leaving missing dependencies." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Fixed eval parser, runner, summary, executor, and relay-check issues for PR #1092; scoped verification passed, full verification blocked by incomplete dependency install and GitHub mergeability is dirty.", + "approach": "Standard approach", + "confidence": 0.7 + }, + "commits": [], + "filesChanged": [], + "projectId": "/home/daytona/workspace", + "tags": [], + "_trace": { + "startRef": "5e63ef398e6376b6c96d9cffeae9c1b668ab45cf", + "endRef": "5e63ef398e6376b6c96d9cffeae9c1b668ab45cf" + } +} \ No newline at end of file diff --git a/evals/PLAN.md b/evals/PLAN.md new file mode 100644 index 000000000..01fc29c10 --- /dev/null +++ b/evals/PLAN.md @@ -0,0 +1,193 @@ +# Relay Evals — Master Plan + +Goal: an extensive, deterministic eval suite that proves **agents using the +Relay protocol + `@agent-relay/sdk` behave as expected across every SDK +surface**. Sibling projects `../agent-assistant` and `../relayfile` already run +this style of suite; we adopt the same substrate so tooling, CI, and artifacts +are consistent. + +## Architecture (mirror `../relayfile`) + +`../relayfile` consumes the reusable eval substrate published as +`@agent-assistant/telemetry/evals` and adds product-owned cases + an executor. +We do the same for relay. + +``` +evals/ + README.md + PLAN.md # this file (source of truth) + suites/ + / + cases.md # HUMAN-AUTHORED source of truth + cases.jsonl # GENERATED by compile-cases.mjs — do not edit + rubric.md # what "passing" means for this suite +scripts/evals/ + compile-cases.mjs # cases.md -> cases.jsonl (port from relayfile) + run-relay-evals.mjs # loads suites, runs executor, writes artifacts + relay-executor.mjs # exercises @agent-relay/sdk in-memory, returns observed state + relay-checks.mjs # deterministic expectation checks + ci-summary.mjs # CI summary +.github/workflows/relay-evals.yml +.relay/evals/runs/ # run artifacts (gitignored) +``` + +npm scripts (root `package.json`): +``` +"evals:compile": "node scripts/evals/compile-cases.mjs", +"evals": "npm run evals:compile && node scripts/evals/run-relay-evals.mjs", +"evals:list": "npm run evals:compile && node scripts/evals/run-relay-evals.mjs --list", +"evals:offline": "npm run evals:compile && node scripts/evals/run-relay-evals.mjs --mode offline" +``` + +## cases.md format (verbatim from relayfile — do not invent a new one) + +``` +# + + +## . +Executor: relay +Kind: regression | capability +Tags: a, b +Human Review: false + +### Message + + +### Mock +```json +{ "...seed state for the in-memory relay..." } +``` + +### Operations +```json +[ { "op": "", "...": "..." } ] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- ... +must: +- ... +mustNot: +- ... +``` + +## Executor operation vocabulary (W1 owns + extends; authors target these) + +Each operation maps to a real SDK / MCP surface call run against an in-memory +relay (NO live broker). Authors: if you need an op not listed, request it in +`#relay-evals` so W1 adds it before you depend on it. + +- messaging: `post_message`, `send_dm`, `send_group_dm`, `reply_to_thread`, + `add_reaction`, `remove_reaction`, `list_messages`, `search_messages`, + `mark_read`, `get_thread`, `get_readers`, `check_inbox` +- channels: `create_channel`, `join_channel`, `leave_channel`, + `invite_to_channel`, `archive_channel`, `set_topic`, `list_channels`, + `list_dms`, `create_workspace`, `set_workspace_key` +- agents: `register_agent`, `add_agent`, `remove_agent`, `list_agents` +- delivery: `deliver` with `mode: wait|steer` +- actions: `register_action`, `invoke_action` +- session: `define_harness`, `resume_session` + +`relay-checks.mjs` deterministic keys (port from relayfile, extend as needed): +`ok`, `contentIncludes`, `toolCallsInclude`, `minToolCalls`, `must`, `mustNot`, +plus relay-specific: `messageExists`, `threadReplyCount`, `reactionCount`, +`channelMembers`, `agentPresence`, `errorCode`, `eventEmitted`. + +## Construction guidance for the executor + +The executor builds clients exactly like the existing vitest suites do. Read +these first — they are the template for the in-memory harness: +`packages/sdk/src/__tests__/{messaging,delivery-actions,facade,listeners,register-action-relay,relaycast-errors}.test.ts`. +Prefer the SDK's existing in-memory primitives (`InMemoryAgentRelayActions`, +`createWorkspaceFacade`, mock messaging client) over standing up a real broker. + +## Surface ownership (workers) + +| Worker | Surfaces / suites | +|---|---| +| **W1 eval-harness** | substrate: compile/run/executor/checks/ci-summary, npm scripts, CI workflow, `evals/README.md`, + reference suite `protocol-framing` (envelope encode/decode, protocol↔SDK type conformance) proving the harness end-to-end | +| **W2 messaging** | `messaging` (post/dm/group-dm), `threads` (reply_to_thread, get_thread, reply counts), `reactions` (add/remove, counts), `read-receipts` (mark_read, get_readers, inbox), `search` (search_messages, list_messages) | +| **W3 channels** | `channels` (create/join/leave/invite/archive/set_topic), `workspaces` (create_workspace/set_workspace_key), `agent-directory` (register/add/remove/list_agents, list_channels, list_dms, presence) | +| **W4 delivery-actions** | `delivery-modes` (wait vs steer, retries/backoff via DeliveryRunner), `actions` (register/invoke via InMemoryAgentRelayActions/ActionRegistry), `action-schema` (json-schema-lite validation, actionSchemaToJsonSchema), `action-errors` (Registration/NotFound/Validation), `capabilities` (RelayCapabilityError) | +| **W5 session-listeners-errors** | `session` (defineHarness, nextHarnessName, normalizeAgentIdentity, resume/continuity, MINIMAL_AGENT_SESSION_CAPABILITIES), `listeners` (hub + MessageCreated/Read/Reacted/Action/Status/ToolCalled predicates, matchesSelector, toPublicMessagingEvent), `facade` (createWorkspaceFacade, notify handler, agent client), `auth-errors` (invalid agent token detect/recover) | + +Target **≥ 15–25 cases per worker** (mix regression + capability). Total goal: +a large suite (100+ cases) covering every surface above. + +## Workflow rules (CLAUDE.md) + +- Branch: `feature/relay-evals`. NEVER push to main. +- Commit to the feature branch only; the operator merges. +- Keep `cases.jsonl` generated (never hand-edit); commit both `cases.md` and the + compiled `cases.jsonl`. +- Run `npm run evals:offline -- --suite ` and ensure it is green before + reporting your suite done. +- Coordinate format/op questions in `#relay-evals`. Progress to the operator is + relayed by the **slack-comms** agent — post status to `#relay-evals` and it + will surface it; do not DM the operator directly. + +## Op argument reference (LOCKED by W1) + +Confirmed canonical by W1 (eval-harness). Verb names below are stable — author +against them. Clarifications W1 added: `get_thread` accepts `{messageId}` or +`{parent}`; `reply_to_thread` is `{as, parent, text, id?}`; `register_agents` is +`{agents:[...]}`; all create/message ops accept optional `id` for deterministic +checks. SDK-export aliases the executor maps: `format_handle`→`formatAgentHandle`, +`token_recovery_message`→`agentTokenRecoveryMessage`. Every op takes `op` plus an +acting identity via `as` where an agent context is required. + +Messaging: +- `post_message` {as, channel, text, id?, idempotencyKey?, attachments?} +- `send_dm` {as, to, text, id?} +- `send_group_dm` {as, participants:[], name?, text, id?} +- `reply_to_thread` {as, parent:, text, id?} +- `get_thread` {messageId} +- `add_reaction` / `remove_reaction` {as, messageId, emoji} +- `mark_read` {as, messageId}; `get_readers` {messageId}; `check_inbox` {as} +- `list_messages` {channel, limit?}; `search_messages` {query, channel?} + +Session/listeners/facade/errors (thin wrappers over exported SDK fns): +- session: `define_harness`{name,version?,input?}, `next_harness_name`{base,explicit?}, + `normalize_identity`{input}, `format_handle`{name}, `read_capabilities`{}, + `resume_session`{...} +- listeners: `add_listener`{selector}, `on_predicate`{predicate}, `emit_event`{raw}, + `emit_session_event`{agentId,event}, `match_selector`{selector,type}, `to_public_event`{raw} +- facade: `register_agent`/`register_agents`{[...]}, `reconnect`{apiToken}, + `notify`{target,options}, `workspace_info`{} +- auth-errors: `is_invalid_token_error`{error}, `is_invalid_token_tool_result`{result}, + `token_recovery_message`{} + +**Create ops accept optional `id`** to pin the created message's id so later ops +and checks reference it deterministically; otherwise only seeded ids are stable. + +Mock seed shape: +```json +{ "agents": [{"name":"...","type":"agent"}], + "channels": [{"name":"...","members":["..."]}], + "messages": [{"id":"...","channel":"...","from":"...","text":"...","threadParent":"?"}] } +``` + +## Executor observed-result contract (W1 implements) + +So pure-function and event ops are checkable, the executor populates per run: +- `observed.content` — stringified return value of each op (drives `contentIncludes`) +- `observed.events[]` — emitted events (drives `eventEmitted`) +- `observed.error.code` — error code when an op throws (drives `errorCode`) +- `observed.toolCalls[]` — op/verb trace (drives `toolCallsInclude`/`minToolCalls`) +- `register_action` uses JSON-safe `handlerFixture` names instead of inline + functions. Supported fixtures: `echo_text`, `sum_numbers`, `throw_error`, + `invalid_output`, and `policy_deny`. The executor also accepts `fixture` as + an alias for `handlerFixture`. + +Relay-specific check keys (bullet arrays of objects): +- `messageExists`: {channel?|kind?, text, from?} — an observed msg matches all fields +- `threadReplyCount`: {parent:, count} +- `reactionCount`: {messageId, emoji, count} +- `channelMembers`: {channel, members:[...]} +- `agentPresence`: {name, status} +- `errorCode`: string | string[] +- `eventEmitted`: string | {type, ...} +Plus shared `ok`, `contentIncludes`, `must`, `mustNot`, `toolCallsInclude`, `minToolCalls`. diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 000000000..f6d150e26 --- /dev/null +++ b/evals/README.md @@ -0,0 +1,64 @@ +# Relay Evals + +Relay uses the shared `@agent-assistant/telemetry/evals` helpers for eval case +loading, filtering, deterministic checks, and run artifacts. Relay owns the +domain-specific suites, rubrics, and in-memory SDK executor in this repository. + +## Layout + +```text +evals/ + PLAN.md + README.md + suites/ + / + cases.md + cases.jsonl + rubric.md +scripts/evals/ + compile-cases.mjs + run-relay-evals.mjs + relay-executor.mjs + relay-checks.mjs + ci-summary.mjs +``` + +## Case Shape + +Author cases in `cases.md`; `cases.jsonl` is generated and should not be edited +by hand. A typical case looks like: + +```json +{"id":"messaging.example","suite":"messaging","executor":"relay","kind":"capability","input":{"message":"Post a channel message","operation":[{"op":"post_message","as":"Lead","channel":"general","text":"hello"}]},"expected":{"ok":true,"messageExists":[{"channel":"general","text":"hello","from":"Lead"}]},"tags":["messaging"]} +``` + +## Commands + +```bash +npm run evals:compile +npm run evals:list +npm run evals:offline +npm run evals:offline -- --suite messaging +npm run evals:offline -- --tag pending-executor +``` + +Run artifacts are written under `.relay/evals/runs/` and are ignored by git. +Each run writes `result.json`, `summary.md`, and `human-review.md`. + +## Executor + +`scripts/evals/relay-executor.mjs` runs the SDK against an in-memory Relay +model. It does not connect to a live broker. The executor records: + +- `observed.content` for `contentIncludes` checks. +- `observed.events[]` for `eventEmitted` checks. +- `observed.error.code` for `errorCode` checks. +- `observed.toolCalls[]` for tool-call checks. + +The operation vocabulary and argument contract live in `evals/PLAN.md`. + +## Practice + +Keep capability and regression cases separate with tags. Prefer deterministic +checks first. Mark cases `Human Review: true` only when the executor substrate +is intentionally pending or the rubric requires manual judgment. diff --git a/evals/suites/action-errors/cases.jsonl b/evals/suites/action-errors/cases.jsonl new file mode 100644 index 000000000..4019b973e --- /dev/null +++ b/evals/suites/action-errors/cases.jsonl @@ -0,0 +1,9 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"action-errors.duplicate-registration-error","suite":"action-errors","executor":"relay","kind":"regression","input":{"message":"Registering the same normalized action name twice should throw ActionRegistrationError.","operation":[{"op":"register_action","as":"builder","name":"echo","handlerFixture":"echo_text"},{"op":"register_action","as":"builder","name":" echo ","handlerFixture":"echo_text"}]},"expected":{"ok":false,"errorCode":["action_registration_error"],"contentIncludes":["action_registration_error"],"must":["Normalize names before duplicate detection.","Surface error code `action_registration_error`."],"mustNot":["Register two actions that differ only by whitespace."],"humanReviewRequired":false},"tags":["actions","errors","registration"],"mock":{"agents":[{"name":"builder","type":"agent"}]}} +{"id":"action-errors.empty-name-registration-error","suite":"action-errors","executor":"relay","kind":"regression","input":{"message":"An empty action name should be rejected during registration.","operation":[{"op":"register_action","as":"builder","name":" ","handlerFixture":"echo_text"}]},"expected":{"ok":false,"errorCode":["action_registration_error"],"contentIncludes":["action_registration_error"],"must":["Trim the action name before checking for presence."],"mustNot":["Register a blank action descriptor."],"humanReviewRequired":false},"tags":["actions","errors","registration"],"mock":{"agents":[{"name":"builder","type":"agent"}]}} +{"id":"action-errors.execute-missing-action-throws-not-found","suite":"action-errors","executor":"relay","kind":"regression","input":{"message":"Executing a missing action should map to ActionNotFoundError.","operation":[{"op":"invoke_action","as":"planner","name":"missing","input":{"text":"nope"},"mode":"execute"}]},"expected":{"ok":false,"errorCode":["action_not_found"],"contentIncludes":["action_not_found"],"must":["Throw ActionNotFoundError for execute-style lookup failures."],"mustNot":["Treat a missing action as a successful no-op."],"humanReviewRequired":false},"tags":["actions","errors","not-found"],"mock":{"agents":[{"name":"planner","type":"agent"}],"actionInvokeMode":"execute"}} +{"id":"action-errors.invoke-missing-action-result","suite":"action-errors","executor":"relay","kind":"capability","input":{"message":"Invoking a missing action through invoke_action should return a structured action_not_found result.","operation":[{"op":"invoke_action","as":"planner","name":"missing","input":{"text":"nope"}}]},"expected":{"ok":true,"contentIncludes":["action_not_found","Unknown action: missing"],"must":["Return ok false inside the action result without throwing from invoke."],"mustNot":["Emit action.invoked for an unknown action."],"humanReviewRequired":false},"tags":["actions","errors","result"],"mock":{"agents":[{"name":"planner","type":"agent"}]}} +{"id":"action-errors.invalid-input-validation-error","suite":"action-errors","executor":"relay","kind":"regression","input":{"message":"Execute-style invocation with invalid input should map to ActionValidationError for the input phase.","operation":[{"op":"register_action","as":"builder","name":"needs-name","handlerFixture":"echo_text","inputSchema":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}},{"op":"invoke_action","as":"planner","name":"needs-name","input":{},"mode":"execute"}]},"expected":{"ok":false,"errorCode":["action_validation_error"],"contentIncludes":["input","action_validation_error"],"must":["Throw ActionValidationError with phase input.","Preserve the validation issue path."],"mustNot":["Call the handler when input validation fails."],"humanReviewRequired":false},"tags":["actions","errors","validation"],"mock":{"agents":[{"name":"builder","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"action-errors.invalid-output-validation-error","suite":"action-errors","executor":"relay","kind":"regression","input":{"message":"Execute-style invocation with invalid output should map to ActionValidationError for the output phase.","operation":[{"op":"register_action","as":"builder","name":"bad-output","handlerFixture":"invalid_output","outputSchema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}},{"op":"invoke_action","as":"planner","name":"bad-output","input":{},"mode":"execute"}]},"expected":{"ok":false,"errorCode":["action_validation_error"],"contentIncludes":["output","action_validation_error"],"must":["Throw ActionValidationError with phase output.","Include output validation details."],"mustNot":["Return invalid handler output as a successful execute result."],"humanReviewRequired":false},"tags":["actions","errors","validation","output"],"mock":{"agents":[{"name":"builder","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"action-errors.validation-message-limits-issues","suite":"action-errors","executor":"relay","kind":"capability","input":{"message":"ActionValidationError messages should summarize validation issues deterministically.","operation":[{"op":"register_action","as":"builder","name":"strict","handlerFixture":"echo_text","inputSchema":{"type":"object","required":["a","b","c","d"],"additionalProperties":false,"properties":{"a":{"type":"string"},"b":{"type":"string"},"c":{"type":"string"},"d":{"type":"string"}}}},{"op":"invoke_action","as":"planner","name":"strict","input":{},"mode":"execute"}]},"expected":{"ok":false,"errorCode":["action_validation_error"],"contentIncludes":["action_validation_error"],"must":["Produce a deterministic ActionValidationError message from validation issues.","Include at least the first three issue paths."],"mustNot":["Emit a success event for invalid input."],"humanReviewRequired":false},"tags":["actions","errors","validation-message"],"mock":{"agents":[{"name":"builder","type":"agent"},{"name":"planner","type":"agent"}]}} diff --git a/evals/suites/action-errors/cases.md b/evals/suites/action-errors/cases.md new file mode 100644 index 000000000..a6372472c --- /dev/null +++ b/evals/suites/action-errors/cases.md @@ -0,0 +1,289 @@ +# Action Errors Cases +Action error cases verify the SDK's typed action errors and error-code mapping for registration, lookup, input validation, and output validation. + +## action-errors.duplicate-registration-error +Executor: relay +Kind: regression +Tags: actions, errors, registration +Human Review: false + +### Message +Registering the same normalized action name twice should throw ActionRegistrationError. + +### Mock +```json +{ + "agents": [{ "name": "builder", "type": "agent" }] +} +``` + +### Operations +```json +[ + { "op": "register_action", "as": "builder", "name": "echo", "handlerFixture": "echo_text" }, + { "op": "register_action", "as": "builder", "name": " echo ", "handlerFixture": "echo_text" } +] +``` + +### Deterministic Checks +ok: false +errorCode: action_registration_error +contentIncludes: +- action_registration_error +must: +- Normalize names before duplicate detection. +- Surface error code `action_registration_error`. +mustNot: +- Register two actions that differ only by whitespace. + +## action-errors.empty-name-registration-error +Executor: relay +Kind: regression +Tags: actions, errors, registration +Human Review: false + +### Message +An empty action name should be rejected during registration. + +### Mock +```json +{ + "agents": [{ "name": "builder", "type": "agent" }] +} +``` + +### Operations +```json +[ + { "op": "register_action", "as": "builder", "name": " ", "handlerFixture": "echo_text" } +] +``` + +### Deterministic Checks +ok: false +errorCode: action_registration_error +contentIncludes: +- action_registration_error +must: +- Trim the action name before checking for presence. +mustNot: +- Register a blank action descriptor. + +## action-errors.execute-missing-action-throws-not-found +Executor: relay +Kind: regression +Tags: actions, errors, not-found +Human Review: false + +### Message +Executing a missing action should map to ActionNotFoundError. + +### Mock +```json +{ + "agents": [{ "name": "planner", "type": "agent" }], + "actionInvokeMode": "execute" +} +``` + +### Operations +```json +[ + { "op": "invoke_action", "as": "planner", "name": "missing", "input": { "text": "nope" }, "mode": "execute" } +] +``` + +### Deterministic Checks +ok: false +errorCode: action_not_found +contentIncludes: +- action_not_found +must: +- Throw ActionNotFoundError for execute-style lookup failures. +mustNot: +- Treat a missing action as a successful no-op. + +## action-errors.invoke-missing-action-result +Executor: relay +Kind: capability +Tags: actions, errors, result +Human Review: false + +### Message +Invoking a missing action through invoke_action should return a structured action_not_found result. + +### Mock +```json +{ + "agents": [{ "name": "planner", "type": "agent" }] +} +``` + +### Operations +```json +[ + { "op": "invoke_action", "as": "planner", "name": "missing", "input": { "text": "nope" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- action_not_found +- Unknown action: missing +must: +- Return ok false inside the action result without throwing from invoke. +mustNot: +- Emit action.invoked for an unknown action. + +## action-errors.invalid-input-validation-error +Executor: relay +Kind: regression +Tags: actions, errors, validation +Human Review: false + +### Message +Execute-style invocation with invalid input should map to ActionValidationError for the input phase. + +### Mock +```json +{ + "agents": [ + { "name": "builder", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "builder", + "name": "needs-name", + "handlerFixture": "echo_text", + "inputSchema": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } } + } + }, + { "op": "invoke_action", "as": "planner", "name": "needs-name", "input": {}, "mode": "execute" } +] +``` + +### Deterministic Checks +ok: false +errorCode: action_validation_error +contentIncludes: +- input +- action_validation_error +must: +- Throw ActionValidationError with phase input. +- Preserve the validation issue path. +mustNot: +- Call the handler when input validation fails. + +## action-errors.invalid-output-validation-error +Executor: relay +Kind: regression +Tags: actions, errors, validation, output +Human Review: false + +### Message +Execute-style invocation with invalid output should map to ActionValidationError for the output phase. + +### Mock +```json +{ + "agents": [ + { "name": "builder", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "builder", + "name": "bad-output", + "handlerFixture": "invalid_output", + "outputSchema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + { "op": "invoke_action", "as": "planner", "name": "bad-output", "input": {}, "mode": "execute" } +] +``` + +### Deterministic Checks +ok: false +errorCode: action_validation_error +contentIncludes: +- output +- action_validation_error +must: +- Throw ActionValidationError with phase output. +- Include output validation details. +mustNot: +- Return invalid handler output as a successful execute result. + +## action-errors.validation-message-limits-issues +Executor: relay +Kind: capability +Tags: actions, errors, validation-message +Human Review: false + +### Message +ActionValidationError messages should summarize validation issues deterministically. + +### Mock +```json +{ + "agents": [ + { "name": "builder", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "builder", + "name": "strict", + "handlerFixture": "echo_text", + "inputSchema": { + "type": "object", + "required": ["a", "b", "c", "d"], + "additionalProperties": false, + "properties": { + "a": { "type": "string" }, + "b": { "type": "string" }, + "c": { "type": "string" }, + "d": { "type": "string" } + } + } + }, + { "op": "invoke_action", "as": "planner", "name": "strict", "input": {}, "mode": "execute" } +] +``` + +### Deterministic Checks +ok: false +errorCode: action_validation_error +contentIncludes: +- action_validation_error +must: +- Produce a deterministic ActionValidationError message from validation issues. +- Include at least the first three issue paths. +mustNot: +- Emit a success event for invalid input. diff --git a/evals/suites/action-errors/rubric.md b/evals/suites/action-errors/rubric.md new file mode 100644 index 000000000..daa414353 --- /dev/null +++ b/evals/suites/action-errors/rubric.md @@ -0,0 +1,3 @@ +# Action Errors Rubric + +Action error cases pass when the executor preserves the SDK's typed error boundaries. Registration failures should throw `ActionRegistrationError` with code `action_registration_error`; execute-style missing actions should throw `ActionNotFoundError`; execute-style invalid input or output should throw `ActionValidationError` with the correct phase and issue paths. Invoke-style missing actions should remain structured action results. Passing runs should expose `observed.error.code` for thrown errors and avoid registering or invoking invalid actions after the failing boundary. diff --git a/evals/suites/action-schema/cases.jsonl b/evals/suites/action-schema/cases.jsonl new file mode 100644 index 000000000..a86b3e2ad --- /dev/null +++ b/evals/suites/action-schema/cases.jsonl @@ -0,0 +1,11 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"action-schema.valid-object-input","suite":"action-schema","executor":"relay","kind":"regression","input":{"message":"A valid object input should satisfy the registered JSON-schema-lite input contract.","operation":[{"op":"register_action","as":"schema-bot","name":"echo","handlerFixture":"echo_text","inputSchema":{"type":"object","required":["text"],"additionalProperties":false,"properties":{"text":{"type":"string","minLength":1}}}},{"op":"invoke_action","as":"planner","name":"echo","input":{"text":"schema ok"}}]},"expected":{"ok":true,"contentIncludes":["schema ok","echoed"],"eventEmitted":["action.completed"],"must":["Accept an object containing the required non-empty string field."],"mustNot":["Report invalid_input for valid input."],"humanReviewRequired":false},"tags":["schema","valid","actions"],"mock":{"agents":[{"name":"schema-bot","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"action-schema.missing-required-field","suite":"action-schema","executor":"relay","kind":"regression","input":{"message":"Missing required input fields should fail validation before the handler runs.","operation":[{"op":"register_action","as":"schema-bot","name":"needs-text","handlerFixture":"echo_text","inputSchema":{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}},{"op":"invoke_action","as":"planner","name":"needs-text","input":{}}]},"expected":{"ok":true,"contentIncludes":["invalid_input","$.text","required property is missing"],"eventEmitted":["action.failed"],"must":["Reject missing required fields with an invalid_input result.","Avoid invoking the handler after input validation fails."],"mustNot":["Return echoed output for invalid input."],"humanReviewRequired":false},"tags":["schema","required","invalid"],"mock":{"agents":[{"name":"schema-bot","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"action-schema.additional-properties-rejected","suite":"action-schema","executor":"relay","kind":"regression","input":{"message":"additionalProperties false should reject unknown input keys.","operation":[{"op":"register_action","as":"schema-bot","name":"strict-echo","handlerFixture":"echo_text","inputSchema":{"type":"object","required":["text"],"additionalProperties":false,"properties":{"text":{"type":"string"}}}},{"op":"invoke_action","as":"planner","name":"strict-echo","input":{"text":"ok","extra":true}}]},"expected":{"ok":true,"contentIncludes":["invalid_input","$.extra","additional property is not allowed"],"eventEmitted":["action.failed"],"must":["Report the unknown property path in validation output."],"mustNot":["Silently strip unknown fields and run the handler."],"humanReviewRequired":false},"tags":["schema","additional-properties","invalid"],"mock":{"agents":[{"name":"schema-bot","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"action-schema.array-items-and-min-items","suite":"action-schema","executor":"relay","kind":"capability","input":{"message":"Array item schemas and minItems constraints should both be enforced.","operation":[{"op":"register_action","as":"schema-bot","name":"tag-echo","handlerFixture":"echo_text","inputSchema":{"type":"object","required":["text","tags"],"properties":{"text":{"type":"string"},"tags":{"type":"array","minItems":2,"items":{"type":"string"}}}}},{"op":"invoke_action","as":"planner","name":"tag-echo","input":{"text":"tags","tags":["ok",3]}}]},"expected":{"ok":true,"contentIncludes":["invalid_input","$.tags[1]","expected string"],"must":["Validate every array element against the item schema."],"mustNot":["Accept non-string tag elements."],"humanReviewRequired":false},"tags":["schema","array","invalid"],"mock":{"agents":[{"name":"schema-bot","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"action-schema.enum-and-const-valid","suite":"action-schema","executor":"relay","kind":"capability","input":{"message":"Enum and const constraints should accept matching literal values.","operation":[{"op":"register_action","as":"schema-bot","name":"literal-echo","handlerFixture":"echo_text","inputSchema":{"type":"object","required":["text","kind","state"],"properties":{"text":{"type":"string"},"kind":{"const":"review"},"state":{"enum":["open","closed"]}}}},{"op":"invoke_action","as":"planner","name":"literal-echo","input":{"text":"literal","kind":"review","state":"open"}}]},"expected":{"ok":true,"contentIncludes":["literal","echoed"],"must":["Accept matching const and enum values."],"mustNot":["Treat literal constraints as unsupported schema keywords."],"humanReviewRequired":false},"tags":["schema","enum","const"],"mock":{"agents":[{"name":"schema-bot","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"action-schema.one-of-rejects-zero-matches","suite":"action-schema","executor":"relay","kind":"regression","input":{"message":"oneOf should reject values that match none of the supplied schemas.","operation":[{"op":"register_action","as":"schema-bot","name":"one-of","handlerFixture":"echo_text","inputSchema":{"oneOf":[{"type":"object","required":["kind","text"],"properties":{"kind":{"const":"ok"},"text":{"type":"string"}}},{"type":"object","required":["kind","text"],"properties":{"kind":{"enum":["error"]},"text":{"type":"string"}}}]}},{"op":"invoke_action","as":"planner","name":"one-of","input":{"kind":"other","text":"x"}}]},"expected":{"ok":true,"contentIncludes":["invalid_input","oneOf","matched 0"],"must":["Report that the value matched zero oneOf schemas."],"mustNot":["Fall through to the handler when oneOf fails."],"humanReviewRequired":false},"tags":["schema","oneOf","invalid"],"mock":{"agents":[{"name":"schema-bot","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"action-schema.output-schema-invalid","suite":"action-schema","executor":"relay","kind":"regression","input":{"message":"Invalid handler output should be validated and returned as invalid_output.","operation":[{"op":"register_action","as":"schema-bot","name":"bad-output","handlerFixture":"invalid_output","outputSchema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}},{"op":"invoke_action","as":"planner","name":"bad-output","input":{"text":"ignored"}}]},"expected":{"ok":true,"contentIncludes":["invalid_output","$.ok","required property is missing"],"eventEmitted":["action.failed"],"must":["Validate handler output against outputSchema.","Return ok false with invalid_output."],"mustNot":["Emit action.completed for invalid output."],"humanReviewRequired":false},"tags":["schema","output","invalid"],"mock":{"agents":[{"name":"schema-bot","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"action-schema.schema-descriptor-passthrough","suite":"action-schema","executor":"relay","kind":"capability","input":{"message":"JSON-schema-lite objects should pass through as action descriptor schemas.","operation":[{"op":"register_action","as":"schema-bot","name":"described","description":"Descriptor schema check","handlerFixture":"echo_text","inputSchema":{"type":"object","title":"EchoInput","required":["text"],"properties":{"text":{"type":"string","description":"Text to echo"}}}}]},"expected":{"ok":true,"contentIncludes":["EchoInput","Text to echo","described"],"must":["Preserve JSON-schema-lite object fields on the registered descriptor."],"mustNot":["Replace an object schema with an empty permissive schema."],"humanReviewRequired":false},"tags":["schema","descriptor","json-schema"],"mock":{"agents":[{"name":"schema-bot","type":"agent"}]}} +{"id":"action-schema.zod-like-coercion-fixture","suite":"action-schema","executor":"relay","kind":"capability","input":{"message":"A zod-like fixture should parse input before the handler sees it.","operation":[{"op":"register_action","as":"schema-bot","name":"coerce-count","handlerFixture":"sum_numbers","inputSchemaFixture":"coerce_string_count"},{"op":"invoke_action","as":"planner","name":"coerce-count","input":{"count":"21"}}]},"expected":{"ok":true,"contentIncludes":[42],"eventEmitted":["action.completed"],"must":["Pass parsed numeric values to the handler after zod-like safeParse succeeds."],"mustNot":["Validate the raw string values as JSON numbers before coercion."],"humanReviewRequired":false},"tags":["schema","coercion","zod-like"],"mock":{"agents":[{"name":"schema-bot","type":"agent"},{"name":"planner","type":"agent"}]}} diff --git a/evals/suites/action-schema/cases.md b/evals/suites/action-schema/cases.md new file mode 100644 index 000000000..ade98f1ef --- /dev/null +++ b/evals/suites/action-schema/cases.md @@ -0,0 +1,454 @@ +# Action Schema Cases +Action schema cases verify JSON-schema-lite validation and actionSchemaToJsonSchema descriptor behavior for valid inputs, invalid inputs, coercion-like fixtures, required fields, and additional property boundaries. + +## action-schema.valid-object-input +Executor: relay +Kind: regression +Tags: schema, valid, actions +Human Review: false + +### Message +A valid object input should satisfy the registered JSON-schema-lite input contract. + +### Mock +```json +{ + "agents": [ + { "name": "schema-bot", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "schema-bot", + "name": "echo", + "handlerFixture": "echo_text", + "inputSchema": { + "type": "object", + "required": ["text"], + "additionalProperties": false, + "properties": { "text": { "type": "string", "minLength": 1 } } + } + }, + { "op": "invoke_action", "as": "planner", "name": "echo", "input": { "text": "schema ok" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- schema ok +- echoed +eventEmitted: +- action.completed +must: +- Accept an object containing the required non-empty string field. +mustNot: +- Report invalid_input for valid input. + +## action-schema.missing-required-field +Executor: relay +Kind: regression +Tags: schema, required, invalid +Human Review: false + +### Message +Missing required input fields should fail validation before the handler runs. + +### Mock +```json +{ + "agents": [ + { "name": "schema-bot", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "schema-bot", + "name": "needs-text", + "handlerFixture": "echo_text", + "inputSchema": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" } } + } + }, + { "op": "invoke_action", "as": "planner", "name": "needs-text", "input": {} } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- invalid_input +- $.text +- required property is missing +eventEmitted: +- action.failed +must: +- Reject missing required fields with an invalid_input result. +- Avoid invoking the handler after input validation fails. +mustNot: +- Return echoed output for invalid input. + +## action-schema.additional-properties-rejected +Executor: relay +Kind: regression +Tags: schema, additional-properties, invalid +Human Review: false + +### Message +additionalProperties false should reject unknown input keys. + +### Mock +```json +{ + "agents": [ + { "name": "schema-bot", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "schema-bot", + "name": "strict-echo", + "handlerFixture": "echo_text", + "inputSchema": { + "type": "object", + "required": ["text"], + "additionalProperties": false, + "properties": { "text": { "type": "string" } } + } + }, + { "op": "invoke_action", "as": "planner", "name": "strict-echo", "input": { "text": "ok", "extra": true } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- invalid_input +- $.extra +- additional property is not allowed +eventEmitted: +- action.failed +must: +- Report the unknown property path in validation output. +mustNot: +- Silently strip unknown fields and run the handler. + +## action-schema.array-items-and-min-items +Executor: relay +Kind: capability +Tags: schema, array, invalid +Human Review: false + +### Message +Array item schemas and minItems constraints should both be enforced. + +### Mock +```json +{ + "agents": [ + { "name": "schema-bot", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "schema-bot", + "name": "tag-echo", + "handlerFixture": "echo_text", + "inputSchema": { + "type": "object", + "required": ["text", "tags"], + "properties": { + "text": { "type": "string" }, + "tags": { "type": "array", "minItems": 2, "items": { "type": "string" } } + } + } + }, + { "op": "invoke_action", "as": "planner", "name": "tag-echo", "input": { "text": "tags", "tags": ["ok", 3] } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- invalid_input +- $.tags[1] +- expected string +must: +- Validate every array element against the item schema. +mustNot: +- Accept non-string tag elements. + +## action-schema.enum-and-const-valid +Executor: relay +Kind: capability +Tags: schema, enum, const +Human Review: false + +### Message +Enum and const constraints should accept matching literal values. + +### Mock +```json +{ + "agents": [ + { "name": "schema-bot", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "schema-bot", + "name": "literal-echo", + "handlerFixture": "echo_text", + "inputSchema": { + "type": "object", + "required": ["text", "kind", "state"], + "properties": { + "text": { "type": "string" }, + "kind": { "const": "review" }, + "state": { "enum": ["open", "closed"] } + } + } + }, + { "op": "invoke_action", "as": "planner", "name": "literal-echo", "input": { "text": "literal", "kind": "review", "state": "open" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- literal +- echoed +must: +- Accept matching const and enum values. +mustNot: +- Treat literal constraints as unsupported schema keywords. + +## action-schema.one-of-rejects-zero-matches +Executor: relay +Kind: regression +Tags: schema, oneOf, invalid +Human Review: false + +### Message +oneOf should reject values that match none of the supplied schemas. + +### Mock +```json +{ + "agents": [ + { "name": "schema-bot", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "schema-bot", + "name": "one-of", + "handlerFixture": "echo_text", + "inputSchema": { + "oneOf": [ + { + "type": "object", + "required": ["kind", "text"], + "properties": { "kind": { "const": "ok" }, "text": { "type": "string" } } + }, + { + "type": "object", + "required": ["kind", "text"], + "properties": { "kind": { "enum": ["error"] }, "text": { "type": "string" } } + } + ] + } + }, + { "op": "invoke_action", "as": "planner", "name": "one-of", "input": { "kind": "other", "text": "x" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- invalid_input +- oneOf +- matched 0 +must: +- Report that the value matched zero oneOf schemas. +mustNot: +- Fall through to the handler when oneOf fails. + +## action-schema.output-schema-invalid +Executor: relay +Kind: regression +Tags: schema, output, invalid +Human Review: false + +### Message +Invalid handler output should be validated and returned as invalid_output. + +### Mock +```json +{ + "agents": [ + { "name": "schema-bot", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "schema-bot", + "name": "bad-output", + "handlerFixture": "invalid_output", + "outputSchema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + { "op": "invoke_action", "as": "planner", "name": "bad-output", "input": { "text": "ignored" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- invalid_output +- $.ok +- required property is missing +eventEmitted: +- action.failed +must: +- Validate handler output against outputSchema. +- Return ok false with invalid_output. +mustNot: +- Emit action.completed for invalid output. + +## action-schema.schema-descriptor-passthrough +Executor: relay +Kind: capability +Tags: schema, descriptor, json-schema +Human Review: false + +### Message +JSON-schema-lite objects should pass through as action descriptor schemas. + +### Mock +```json +{ + "agents": [{ "name": "schema-bot", "type": "agent" }] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "schema-bot", + "name": "described", + "description": "Descriptor schema check", + "handlerFixture": "echo_text", + "inputSchema": { + "type": "object", + "title": "EchoInput", + "required": ["text"], + "properties": { "text": { "type": "string", "description": "Text to echo" } } + } + } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- EchoInput +- Text to echo +- described +must: +- Preserve JSON-schema-lite object fields on the registered descriptor. +mustNot: +- Replace an object schema with an empty permissive schema. + +## action-schema.zod-like-coercion-fixture +Executor: relay +Kind: capability +Tags: schema, coercion, zod-like +Human Review: false + +### Message +A zod-like fixture should parse input before the handler sees it. + +### Mock +```json +{ + "agents": [ + { "name": "schema-bot", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "schema-bot", + "name": "coerce-count", + "handlerFixture": "sum_numbers", + "inputSchemaFixture": "coerce_string_count" + }, + { "op": "invoke_action", "as": "planner", "name": "coerce-count", "input": { "count": "21" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- 42 +eventEmitted: +- action.completed +must: +- Pass parsed numeric values to the handler after zod-like safeParse succeeds. +mustNot: +- Validate the raw string values as JSON numbers before coercion. diff --git a/evals/suites/action-schema/rubric.md b/evals/suites/action-schema/rubric.md new file mode 100644 index 000000000..2dffaf525 --- /dev/null +++ b/evals/suites/action-schema/rubric.md @@ -0,0 +1,3 @@ +# Action Schema Rubric + +Action schema cases pass when action registration and invocation enforce the SDK's JSON-schema-lite subset and descriptor conversion behavior. Valid inputs should complete; missing required fields, unknown properties, array item errors, failed oneOf matches, and invalid outputs should return deterministic invalid_input or invalid_output results with precise paths in `observed.content`. Descriptor cases should preserve object schema fields. Zod-like fixture cases should prove parsed values reach handlers without requiring live zod dependencies. diff --git a/evals/suites/actions/cases.jsonl b/evals/suites/actions/cases.jsonl new file mode 100644 index 000000000..5f4692641 --- /dev/null +++ b/evals/suites/actions/cases.jsonl @@ -0,0 +1,9 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"actions.register-and-invoke-echo","suite":"actions","executor":"relay","kind":"regression","input":{"message":"An agent registers an echo action and another agent invokes it successfully.","operation":[{"op":"register_action","as":"builder","name":"echo","description":"Echo text","handlerFixture":"echo_text","inputSchema":{"type":"object","required":["text"],"additionalProperties":false,"properties":{"text":{"type":"string","minLength":1}}},"outputSchema":{"type":"object","required":["echoed"],"additionalProperties":false,"properties":{"echoed":{"type":"string"}}}},{"op":"invoke_action","as":"planner","name":" echo ","input":{"text":"hello relay"}}]},"expected":{"ok":true,"contentIncludes":["echoed","hello relay"],"eventEmitted":["action.invoked","action.completed"],"toolCallsInclude":["register_action","invoke_action"],"must":["Normalize action names by trimming whitespace before lookup.","Return an ok action result with the handler output."],"mustNot":["Emit an action.failed event for a valid invocation."],"humanReviewRequired":false},"tags":["actions","register","invoke"],"mock":{"agents":[{"name":"builder","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"actions.list-descriptor-after-register","suite":"actions","executor":"relay","kind":"capability","input":{"message":"A registered action should be visible through registry descriptor lookup with default visibility.","operation":[{"op":"register_action","as":"builder","name":"sum","description":"Add two numbers","handlerFixture":"sum_numbers","inputSchema":{"type":"object","required":["a","b"],"properties":{"a":{"type":"number"},"b":{"type":"number"}}}}]},"expected":{"ok":true,"contentIncludes":["sum","Add two numbers","agent"],"must":["Store the normalized action name in the descriptor.","Default omitted visibility to `agent`."],"mustNot":["Drop the input schema from the descriptor."],"humanReviewRequired":false},"tags":["actions","descriptor"],"mock":{"agents":[{"name":"builder","type":"agent"}]}} +{"id":"actions.invoke-sum-with-caller-context","suite":"actions","executor":"relay","kind":"regression","input":{"message":"Action invocation should carry the caller identity into listener and audit events.","operation":[{"op":"register_action","as":"math-bot","name":"sum","description":"Add numbers","handlerFixture":"sum_numbers","inputSchema":{"type":"object","required":["a","b"],"properties":{"a":{"type":"number"},"b":{"type":"number"}}}},{"op":"invoke_action","as":"planner","name":"sum","input":{"a":2,"b":5},"workspaceId":"ws_eval"}]},"expected":{"ok":true,"contentIncludes":[7],"eventEmitted":[{"type":"action.invoked","action":"sum"},{"type":"action.completed","action":"sum"}],"must":["Include caller `planner` in emitted action events.","Return a successful output of 7."],"mustNot":["Invoke the action without caller context."],"humanReviewRequired":false},"tags":["actions","context","invoke"],"mock":{"agents":[{"name":"math-bot","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"actions.policy-denied-result","suite":"actions","executor":"relay","kind":"capability","input":{"message":"A policy-denied action returns an action_denied result and emits a denied event.","operation":[{"op":"register_action","as":"guard","name":"restricted","description":"Restricted action","handlerFixture":"policy_deny"},{"op":"invoke_action","as":"planner","name":"restricted","input":{"request":"delete"}}]},"expected":{"ok":true,"contentIncludes":["action_denied","denied"],"eventEmitted":["action.invoked","action.denied"],"must":["Return ok false with error code `action_denied`.","Emit action.denied rather than action.completed."],"mustNot":["Run the handler after policy denial."],"humanReviewRequired":false},"tags":["actions","policy","denied"],"mock":{"agents":[{"name":"guard","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"actions.handler-throw-failed-result","suite":"actions","executor":"relay","kind":"regression","input":{"message":"If a registered handler throws, invoke_action should return an action_failed result and emit failure events.","operation":[{"op":"register_action","as":"builder","name":"explode","description":"Throw an error","handlerFixture":"throw_error"},{"op":"invoke_action","as":"planner","name":"explode","input":{"text":"boom"}}]},"expected":{"ok":true,"contentIncludes":["action_failed","fixture threw"],"eventEmitted":["action.invoked","action.failed"],"must":["Convert the thrown error into an action result with ok false.","Emit a listener-visible action.failed event."],"mustNot":["Throw out of invoke_action for handler failures."],"humanReviewRequired":false},"tags":["actions","failure"],"mock":{"agents":[{"name":"builder","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"actions.unregister-handle-removes-action","suite":"actions","executor":"relay","kind":"capability","input":{"message":"The action handle returned by registration should be able to unregister the action.","operation":[{"op":"register_action","as":"builder","name":"temporary","description":"Temporary action","handlerFixture":"echo_text","unregisterAfter":true},{"op":"invoke_action","as":"planner","name":"temporary","input":{"text":"should not run"}}]},"expected":{"ok":true,"contentIncludes":["action_not_found"],"must":["Remove the action when its registration handle is unregistered.","Return a not-found result on later invoke."],"mustNot":["Keep stale unregistered actions invokable."],"humanReviewRequired":false},"tags":["actions","unregister","lookup"],"mock":{"agents":[{"name":"builder","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"actions.listener-errors-do-not-break-invoke","suite":"actions","executor":"relay","kind":"regression","input":{"message":"A throwing registry listener should not prevent successful action invocation.","operation":[{"op":"register_action","as":"builder","name":"echo","description":"Echo text","handlerFixture":"echo_text"},{"op":"invoke_action","as":"planner","name":"echo","input":{"text":"listener resilience"}}]},"expected":{"ok":true,"contentIncludes":["listener resilience","echoed"],"eventEmitted":["action.invoked","action.completed"],"must":["Swallow listener exceptions during event emission.","Complete the action successfully."],"mustNot":["Convert listener exceptions into action_failed results."],"humanReviewRequired":false},"tags":["actions","listeners","resilience"],"mock":{"agents":[{"name":"builder","type":"agent"},{"name":"planner","type":"agent"}],"actionListeners":[{"fixture":"throwing_listener"}]}} diff --git a/evals/suites/actions/cases.md b/evals/suites/actions/cases.md new file mode 100644 index 000000000..9843ef1cd --- /dev/null +++ b/evals/suites/actions/cases.md @@ -0,0 +1,353 @@ +# Actions Cases +Action cases verify registration, lookup, invocation results, policy decisions, and listener/audit events through InMemoryAgentRelayActions and ActionRegistry semantics. + +## actions.register-and-invoke-echo +Executor: relay +Kind: regression +Tags: actions, register, invoke +Human Review: false + +### Message +An agent registers an echo action and another agent invokes it successfully. + +### Mock +```json +{ + "agents": [ + { "name": "builder", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "builder", + "name": "echo", + "description": "Echo text", + "handlerFixture": "echo_text", + "inputSchema": { + "type": "object", + "required": ["text"], + "additionalProperties": false, + "properties": { "text": { "type": "string", "minLength": 1 } } + }, + "outputSchema": { + "type": "object", + "required": ["echoed"], + "additionalProperties": false, + "properties": { "echoed": { "type": "string" } } + } + }, + { "op": "invoke_action", "as": "planner", "name": " echo ", "input": { "text": "hello relay" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- echoed +- hello relay +eventEmitted: +- action.invoked +- action.completed +toolCallsInclude: +- register_action +- invoke_action +must: +- Normalize action names by trimming whitespace before lookup. +- Return an ok action result with the handler output. +mustNot: +- Emit an action.failed event for a valid invocation. + +## actions.list-descriptor-after-register +Executor: relay +Kind: capability +Tags: actions, descriptor +Human Review: false + +### Message +A registered action should be visible through registry descriptor lookup with default visibility. + +### Mock +```json +{ + "agents": [{ "name": "builder", "type": "agent" }] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "builder", + "name": "sum", + "description": "Add two numbers", + "handlerFixture": "sum_numbers", + "inputSchema": { + "type": "object", + "required": ["a", "b"], + "properties": { + "a": { "type": "number" }, + "b": { "type": "number" } + } + } + } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- sum +- Add two numbers +- agent +must: +- Store the normalized action name in the descriptor. +- Default omitted visibility to `agent`. +mustNot: +- Drop the input schema from the descriptor. + +## actions.invoke-sum-with-caller-context +Executor: relay +Kind: regression +Tags: actions, context, invoke +Human Review: false + +### Message +Action invocation should carry the caller identity into listener and audit events. + +### Mock +```json +{ + "agents": [ + { "name": "math-bot", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "math-bot", + "name": "sum", + "description": "Add numbers", + "handlerFixture": "sum_numbers", + "inputSchema": { + "type": "object", + "required": ["a", "b"], + "properties": { + "a": { "type": "number" }, + "b": { "type": "number" } + } + } + }, + { "op": "invoke_action", "as": "planner", "name": "sum", "input": { "a": 2, "b": 5 }, "workspaceId": "ws_eval" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- 7 +eventEmitted: +- { "type": "action.invoked", "action": "sum" } +- { "type": "action.completed", "action": "sum" } +must: +- Include caller `planner` in emitted action events. +- Return a successful output of 7. +mustNot: +- Invoke the action without caller context. + +## actions.policy-denied-result +Executor: relay +Kind: capability +Tags: actions, policy, denied +Human Review: false + +### Message +A policy-denied action returns an action_denied result and emits a denied event. + +### Mock +```json +{ + "agents": [ + { "name": "guard", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "guard", + "name": "restricted", + "description": "Restricted action", + "handlerFixture": "policy_deny" + }, + { "op": "invoke_action", "as": "planner", "name": "restricted", "input": { "request": "delete" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- action_denied +- denied +eventEmitted: +- action.invoked +- action.denied +must: +- Return ok false with error code `action_denied`. +- Emit action.denied rather than action.completed. +mustNot: +- Run the handler after policy denial. + +## actions.handler-throw-failed-result +Executor: relay +Kind: regression +Tags: actions, failure +Human Review: false + +### Message +If a registered handler throws, invoke_action should return an action_failed result and emit failure events. + +### Mock +```json +{ + "agents": [ + { "name": "builder", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "builder", + "name": "explode", + "description": "Throw an error", + "handlerFixture": "throw_error" + }, + { "op": "invoke_action", "as": "planner", "name": "explode", "input": { "text": "boom" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- action_failed +- fixture threw +eventEmitted: +- action.invoked +- action.failed +must: +- Convert the thrown error into an action result with ok false. +- Emit a listener-visible action.failed event. +mustNot: +- Throw out of invoke_action for handler failures. + +## actions.unregister-handle-removes-action +Executor: relay +Kind: capability +Tags: actions, unregister, lookup +Human Review: false + +### Message +The action handle returned by registration should be able to unregister the action. + +### Mock +```json +{ + "agents": [ + { "name": "builder", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "builder", + "name": "temporary", + "description": "Temporary action", + "handlerFixture": "echo_text", + "unregisterAfter": true + }, + { "op": "invoke_action", "as": "planner", "name": "temporary", "input": { "text": "should not run" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- action_not_found +must: +- Remove the action when its registration handle is unregistered. +- Return a not-found result on later invoke. +mustNot: +- Keep stale unregistered actions invokable. + +## actions.listener-errors-do-not-break-invoke +Executor: relay +Kind: regression +Tags: actions, listeners, resilience +Human Review: false + +### Message +A throwing registry listener should not prevent successful action invocation. + +### Mock +```json +{ + "agents": [ + { "name": "builder", "type": "agent" }, + { "name": "planner", "type": "agent" } + ], + "actionListeners": [{ "fixture": "throwing_listener" }] +} +``` + +### Operations +```json +[ + { + "op": "register_action", + "as": "builder", + "name": "echo", + "description": "Echo text", + "handlerFixture": "echo_text" + }, + { "op": "invoke_action", "as": "planner", "name": "echo", "input": { "text": "listener resilience" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- listener resilience +- echoed +eventEmitted: +- action.invoked +- action.completed +must: +- Swallow listener exceptions during event emission. +- Complete the action successfully. +mustNot: +- Convert listener exceptions into action_failed results. diff --git a/evals/suites/actions/rubric.md b/evals/suites/actions/rubric.md new file mode 100644 index 000000000..571a56729 --- /dev/null +++ b/evals/suites/actions/rubric.md @@ -0,0 +1,3 @@ +# Actions Rubric + +Action cases pass when `register_action` and `invoke_action` exercise the same semantics as `InMemoryAgentRelayActions` and `ActionRegistry`: normalized names, stored descriptors, default visibility, caller context propagation, successful fixture outputs, denied policy results, handler failure results, unregister behavior, and resilient listener emission. Passing output should expose action result payloads in `observed.content`, include invoked/completed/failed/denied events in `observed.events`, and trace both registration and invocation operations. diff --git a/evals/suites/agent-directory/cases.jsonl b/evals/suites/agent-directory/cases.jsonl new file mode 100644 index 000000000..f90936dd1 --- /dev/null +++ b/evals/suites/agent-directory/cases.jsonl @@ -0,0 +1,13 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"agent-directory.register-agent-online","suite":"agent-directory","executor":"relay","kind":"capability","input":{"message":"Register a new worker agent and verify it appears online in the agent directory.","operation":[{"op":"register_agent","name":"WorkerA","type":"agent","persona":"Builder"},{"op":"list_agents"}]},"expected":{"ok":true,"contentIncludes":["WorkerA","Builder"],"agentPresence":[{"name":"WorkerA","status":"online"}],"toolCallsInclude":["register_agent","list_agents"],"must":["Add the registered agent to the directory.","Mark a freshly registered agent as online."],"mustNot":["Require a channel join before presence is visible."],"humanReviewRequired":false},"tags":["agents","register","presence"],"mock":{}} +{"id":"agent-directory.register-human-agent-type","suite":"agent-directory","executor":"relay","kind":"capability","input":{"message":"Register a human operator identity and verify the directory preserves its type.","operation":[{"op":"register_agent","name":"Operator","type":"human"},{"op":"list_agents"}]},"expected":{"ok":true,"contentIncludes":["Operator","human"],"agentPresence":[{"name":"Operator","status":"online"}],"must":["Preserve the requested human type in the agent listing."],"mustNot":["Coerce every registered identity to type agent."],"humanReviewRequired":false},"tags":["agents","register"],"mock":{}} +{"id":"agent-directory.register-duplicate-name-rejected","suite":"agent-directory","executor":"relay","kind":"regression","input":{"message":"Registering an agent name that already exists should fail deterministically.","operation":[{"op":"register_agent","name":"WorkerA","type":"agent"}]},"expected":{"ok":false,"errorCode":["agent_exists"],"agentPresence":[{"name":"WorkerA","status":"online"}],"must":["Reject duplicate agent names without replacing the existing identity."],"mustNot":["Mint a new token for an already registered name."],"humanReviewRequired":false},"tags":["agents","register","errors"],"mock":{"agents":[{"name":"WorkerA","type":"agent","status":"online"}]}} +{"id":"agent-directory.add-agent-spawns-online","suite":"agent-directory","executor":"relay","kind":"capability","input":{"message":"Spawn a worker agent through add_agent and verify it appears online.","operation":[{"op":"add_agent","name":"WorkerB","cli":"codex","task":"Handle eval work","persona":"Eval worker"},{"op":"list_agents","status":"online"}]},"expected":{"ok":true,"contentIncludes":["WorkerB","Eval worker"],"agentPresence":[{"name":"WorkerB","status":"online"}],"toolCallsInclude":["add_agent","list_agents"],"must":["Add spawned agents to the directory as online.","Preserve spawn metadata that is useful for auditing."],"mustNot":["Hide spawned agents from online-filtered listings."],"humanReviewRequired":false},"tags":["agents","spawn","presence"],"mock":{"agents":[{"name":"Lead","type":"human","status":"online"}]}} +{"id":"agent-directory.remove-agent-marks-offline","suite":"agent-directory","executor":"relay","kind":"regression","input":{"message":"Removing a worker should transition its presence to offline while retaining directory history.","operation":[{"op":"remove_agent","name":"WorkerB","reason":"done"},{"op":"list_agents"},{"op":"list_agents","status":"offline"}]},"expected":{"ok":true,"contentIncludes":["WorkerB","offline"],"agentPresence":[{"name":"WorkerB","status":"offline"}],"must":["Mark the removed agent offline.","Keep the offline agent visible to unfiltered and offline-filtered listings."],"mustNot":["Leave the removed agent marked online."],"humanReviewRequired":false},"tags":["agents","remove","presence"],"mock":{"agents":[{"name":"WorkerB","type":"agent","status":"online"}]}} +{"id":"agent-directory.remove-unknown-agent-rejected","suite":"agent-directory","executor":"relay","kind":"regression","input":{"message":"Removing an unknown agent should fail without changing known agent presence.","operation":[{"op":"remove_agent","name":"MissingWorker","reason":"not found"}]},"expected":{"ok":false,"errorCode":["agent_not_found"],"agentPresence":[{"name":"WorkerA","status":"online"}],"must":["Return a deterministic not-found error for missing agents."],"mustNot":["Create an offline placeholder for the missing agent."],"humanReviewRequired":false},"tags":["agents","remove","errors"],"mock":{"agents":[{"name":"WorkerA","type":"agent","status":"online"}]}} +{"id":"agent-directory.list-agents-status-filters","suite":"agent-directory","executor":"relay","kind":"capability","input":{"message":"Online and offline status filters should return only agents matching the requested presence.","operation":[{"op":"list_agents","status":"online"},{"op":"list_agents","status":"offline"}]},"expected":{"ok":true,"contentIncludes":["OnlineWorker","OfflineWorker"],"agentPresence":[{"name":"OnlineWorker","status":"online"},{"name":"OfflineWorker","status":"offline"}],"must":["Honor online and offline filters independently."],"mustNot":["Treat every listed agent as online."],"humanReviewRequired":false},"tags":["agents","list","presence"],"mock":{"agents":[{"name":"OnlineWorker","type":"agent","status":"online"},{"name":"OfflineWorker","type":"agent","status":"offline"}]}} +{"id":"agent-directory.list-channels-reflects-membership","suite":"agent-directory","executor":"relay","kind":"capability","input":{"message":"List channels after a worker joins a channel and verify membership is reflected in the directory-facing listing.","operation":[{"op":"join_channel","as":"WorkerA","channel":"alpha"},{"op":"list_channels","as":"WorkerA"}]},"expected":{"ok":true,"contentIncludes":["alpha","beta"],"channelMembers":[{"channel":"alpha","members":["Lead","WorkerA"]},{"channel":"beta","members":["Lead"]}],"must":["Reflect the worker's joined membership in list_channels.","Preserve channels the worker has not joined."],"mustNot":["Treat list_channels as only the caller's joined channels unless the executor documents such a filter."],"humanReviewRequired":false},"tags":["agents","channels","list"],"mock":{"agents":[{"name":"Lead","type":"human"},{"name":"WorkerA","type":"agent"}],"channels":[{"name":"alpha","topic":"Alpha","members":["Lead"]},{"name":"beta","topic":"Beta","members":["Lead"]}]}} +{"id":"agent-directory.list-dms-after-direct-message","suite":"agent-directory","executor":"relay","kind":"capability","input":{"message":"After a direct message is sent, the sender should see the direct-message conversation in list_dms.","operation":[{"op":"send_dm","as":"Lead","to":"WorkerA","text":"Please review the channel suite.","id":"dm_agents_1"},{"op":"list_dms","as":"Lead"}]},"expected":{"ok":true,"contentIncludes":["WorkerA","Please review the channel suite."],"messageExists":[{"kind":"dm","text":"Please review the channel suite.","from":"Lead"}],"toolCallsInclude":["send_dm","list_dms"],"must":["Create or update a DM conversation for the sender and recipient.","Include the latest direct-message content in the conversation summary."],"mustNot":["Expose the DM as a public channel message."],"humanReviewRequired":false},"tags":["agents","dms","list"],"mock":{"agents":[{"name":"Lead","type":"human"},{"name":"WorkerA","type":"agent"}]}} +{"id":"agent-directory.list-dms-is-agent-scoped","suite":"agent-directory","executor":"relay","kind":"regression","input":{"message":"Direct-message listings should be scoped to the acting agent.","operation":[{"op":"list_dms","as":"WorkerA"}]},"expected":{"ok":true,"contentIncludes":["Private A"],"forbidPhrases":["Private B"],"must":["Return conversations involving the acting agent."],"mustNot":["Leak unrelated direct-message conversations to another agent."],"humanReviewRequired":false},"tags":["agents","dms","privacy"],"mock":{"agents":[{"name":"Lead","type":"human"},{"name":"WorkerA","type":"agent"},{"name":"WorkerB","type":"agent"}],"messages":[{"id":"dm_seed_1","kind":"dm","from":"Lead","to":"WorkerA","text":"Private A"},{"id":"dm_seed_2","kind":"dm","from":"Lead","to":"WorkerB","text":"Private B"}]}} +{"id":"agent-directory.presence-offline-agent-cannot-act","suite":"agent-directory","executor":"relay","kind":"regression","input":{"message":"An offline agent should not be allowed to perform channel membership operations as that identity.","operation":[{"op":"join_channel","as":"OfflineWorker","channel":"ops"}]},"expected":{"ok":false,"errorCode":["agent_offline"],"agentPresence":[{"name":"OfflineWorker","status":"offline"}],"channelMembers":[{"channel":"ops","members":[]}],"must":["Reject acting as an offline identity."],"mustNot":["Bring an offline agent online implicitly because it attempted an operation."],"humanReviewRequired":false},"tags":["agents","presence","errors"],"mock":{"agents":[{"name":"OfflineWorker","type":"agent","status":"offline"}],"channels":[{"name":"ops","topic":"Operations","members":[]}]}} diff --git a/evals/suites/agent-directory/cases.md b/evals/suites/agent-directory/cases.md new file mode 100644 index 000000000..426e04a8e --- /dev/null +++ b/evals/suites/agent-directory/cases.md @@ -0,0 +1,458 @@ +# Agent Directory Cases +Agent directory cases pin agent registration, spawned worker lifecycle, directory listings, direct-message directory state, and online/offline presence behavior. + +## agent-directory.register-agent-online +Executor: relay +Kind: capability +Tags: agents, register, presence +Human Review: false + +### Message +Register a new worker agent and verify it appears online in the agent directory. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "register_agent", "name": "WorkerA", "type": "agent", "persona": "Builder" }, + { "op": "list_agents" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- WorkerA +- Builder +agentPresence: +- {"name":"WorkerA","status":"online"} +toolCallsInclude: +- register_agent +- list_agents + +### Must +- Add the registered agent to the directory. +- Mark a freshly registered agent as online. + +### Must Not +- Require a channel join before presence is visible. + +## agent-directory.register-human-agent-type +Executor: relay +Kind: capability +Tags: agents, register +Human Review: false + +### Message +Register a human operator identity and verify the directory preserves its type. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "register_agent", "name": "Operator", "type": "human" }, + { "op": "list_agents" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- Operator +- human +agentPresence: +- {"name":"Operator","status":"online"} + +### Must +- Preserve the requested human type in the agent listing. + +### Must Not +- Coerce every registered identity to type agent. + +## agent-directory.register-duplicate-name-rejected +Executor: relay +Kind: regression +Tags: agents, register, errors +Human Review: false + +### Message +Registering an agent name that already exists should fail deterministically. + +### Mock +```json +{ + "agents": [ + { "name": "WorkerA", "type": "agent", "status": "online" } + ] +} +``` + +### Operations +```json +[ + { "op": "register_agent", "name": "WorkerA", "type": "agent" } +] +``` + +### Deterministic Checks +ok: false +errorCode: agent_exists +agentPresence: +- {"name":"WorkerA","status":"online"} + +### Must +- Reject duplicate agent names without replacing the existing identity. + +### Must Not +- Mint a new token for an already registered name. + +## agent-directory.add-agent-spawns-online +Executor: relay +Kind: capability +Tags: agents, spawn, presence +Human Review: false + +### Message +Spawn a worker agent through add_agent and verify it appears online. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human", "status": "online" } + ] +} +``` + +### Operations +```json +[ + { "op": "add_agent", "name": "WorkerB", "cli": "codex", "task": "Handle eval work", "persona": "Eval worker" }, + { "op": "list_agents", "status": "online" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- WorkerB +- Eval worker +agentPresence: +- {"name":"WorkerB","status":"online"} +toolCallsInclude: +- add_agent +- list_agents + +### Must +- Add spawned agents to the directory as online. +- Preserve spawn metadata that is useful for auditing. + +### Must Not +- Hide spawned agents from online-filtered listings. + +## agent-directory.remove-agent-marks-offline +Executor: relay +Kind: regression +Tags: agents, remove, presence +Human Review: false + +### Message +Removing a worker should transition its presence to offline while retaining directory history. + +### Mock +```json +{ + "agents": [ + { "name": "WorkerB", "type": "agent", "status": "online" } + ] +} +``` + +### Operations +```json +[ + { "op": "remove_agent", "name": "WorkerB", "reason": "done" }, + { "op": "list_agents" }, + { "op": "list_agents", "status": "offline" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- WorkerB +- offline +agentPresence: +- {"name":"WorkerB","status":"offline"} + +### Must +- Mark the removed agent offline. +- Keep the offline agent visible to unfiltered and offline-filtered listings. + +### Must Not +- Leave the removed agent marked online. + +## agent-directory.remove-unknown-agent-rejected +Executor: relay +Kind: regression +Tags: agents, remove, errors +Human Review: false + +### Message +Removing an unknown agent should fail without changing known agent presence. + +### Mock +```json +{ + "agents": [ + { "name": "WorkerA", "type": "agent", "status": "online" } + ] +} +``` + +### Operations +```json +[ + { "op": "remove_agent", "name": "MissingWorker", "reason": "not found" } +] +``` + +### Deterministic Checks +ok: false +errorCode: agent_not_found +agentPresence: +- {"name":"WorkerA","status":"online"} + +### Must +- Return a deterministic not-found error for missing agents. + +### Must Not +- Create an offline placeholder for the missing agent. + +## agent-directory.list-agents-status-filters +Executor: relay +Kind: capability +Tags: agents, list, presence +Human Review: false + +### Message +Online and offline status filters should return only agents matching the requested presence. + +### Mock +```json +{ + "agents": [ + { "name": "OnlineWorker", "type": "agent", "status": "online" }, + { "name": "OfflineWorker", "type": "agent", "status": "offline" } + ] +} +``` + +### Operations +```json +[ + { "op": "list_agents", "status": "online" }, + { "op": "list_agents", "status": "offline" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- OnlineWorker +- OfflineWorker +agentPresence: +- {"name":"OnlineWorker","status":"online"} +- {"name":"OfflineWorker","status":"offline"} + +### Must +- Honor online and offline filters independently. + +### Must Not +- Treat every listed agent as online. + +## agent-directory.list-channels-reflects-membership +Executor: relay +Kind: capability +Tags: agents, channels, list +Human Review: false + +### Message +List channels after a worker joins a channel and verify membership is reflected in the directory-facing listing. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" }, + { "name": "WorkerA", "type": "agent" } + ], + "channels": [ + { "name": "alpha", "topic": "Alpha", "members": ["Lead"] }, + { "name": "beta", "topic": "Beta", "members": ["Lead"] } + ] +} +``` + +### Operations +```json +[ + { "op": "join_channel", "as": "WorkerA", "channel": "alpha" }, + { "op": "list_channels", "as": "WorkerA" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- alpha +- beta +channelMembers: +- {"channel":"alpha","members":["Lead","WorkerA"]} +- {"channel":"beta","members":["Lead"]} + +### Must +- Reflect the worker's joined membership in list_channels. +- Preserve channels the worker has not joined. + +### Must Not +- Treat list_channels as only the caller's joined channels unless the executor documents such a filter. + +## agent-directory.list-dms-after-direct-message +Executor: relay +Kind: capability +Tags: agents, dms, list +Human Review: false + +### Message +After a direct message is sent, the sender should see the direct-message conversation in list_dms. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" }, + { "name": "WorkerA", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { "op": "send_dm", "as": "Lead", "to": "WorkerA", "text": "Please review the channel suite.", "id": "dm_agents_1" }, + { "op": "list_dms", "as": "Lead" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- WorkerA +- Please review the channel suite. +messageExists: +- {"kind":"dm","text":"Please review the channel suite.","from":"Lead"} +toolCallsInclude: +- send_dm +- list_dms + +### Must +- Create or update a DM conversation for the sender and recipient. +- Include the latest direct-message content in the conversation summary. + +### Must Not +- Expose the DM as a public channel message. + +## agent-directory.list-dms-is-agent-scoped +Executor: relay +Kind: regression +Tags: agents, dms, privacy +Human Review: false + +### Message +Direct-message listings should be scoped to the acting agent. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" }, + { "name": "WorkerA", "type": "agent" }, + { "name": "WorkerB", "type": "agent" } + ], + "messages": [ + { "id": "dm_seed_1", "kind": "dm", "from": "Lead", "to": "WorkerA", "text": "Private A" }, + { "id": "dm_seed_2", "kind": "dm", "from": "Lead", "to": "WorkerB", "text": "Private B" } + ] +} +``` + +### Operations +```json +[ + { "op": "list_dms", "as": "WorkerA" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- Private A +forbidPhrases: +- Private B + +### Must +- Return conversations involving the acting agent. + +### Must Not +- Leak unrelated direct-message conversations to another agent. + +## agent-directory.presence-offline-agent-cannot-act +Executor: relay +Kind: regression +Tags: agents, presence, errors +Human Review: false + +### Message +An offline agent should not be allowed to perform channel membership operations as that identity. + +### Mock +```json +{ + "agents": [ + { "name": "OfflineWorker", "type": "agent", "status": "offline" } + ], + "channels": [ + { "name": "ops", "topic": "Operations", "members": [] } + ] +} +``` + +### Operations +```json +[ + { "op": "join_channel", "as": "OfflineWorker", "channel": "ops" } +] +``` + +### Deterministic Checks +ok: false +errorCode: agent_offline +agentPresence: +- {"name":"OfflineWorker","status":"offline"} +channelMembers: +- {"channel":"ops","members":[]} + +### Must +- Reject acting as an offline identity. + +### Must Not +- Bring an offline agent online implicitly because it attempted an operation. diff --git a/evals/suites/agent-directory/rubric.md b/evals/suites/agent-directory/rubric.md new file mode 100644 index 000000000..9d7b4b1fd --- /dev/null +++ b/evals/suites/agent-directory/rubric.md @@ -0,0 +1,5 @@ +# Agent Directory Rubric + +Agent directory cases pass when registration, worker spawning, removal, listing filters, DM directory state, channel listings, and presence transitions are deterministic. + +Passing behavior must preserve agent type and metadata, expose online and offline status accurately, scope DM listings to the acting agent, and return typed errors for duplicate registration, unknown removal targets, and attempts to act as an offline agent. diff --git a/evals/suites/auth-errors/cases.jsonl b/evals/suites/auth-errors/cases.jsonl new file mode 100644 index 000000000..cc6a6c3bb --- /dev/null +++ b/evals/suites/auth-errors/cases.jsonl @@ -0,0 +1,11 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"auth-errors.detect-top-level-code","suite":"auth-errors","executor":"relay","kind":"capability","input":{"message":"Detect an invalid agent token from a top-level typed error code.","operation":[{"op":"is_invalid_token_error","error":{"code":"agent_token_invalid","message":"whatever"}}]},"expected":{"ok":true,"contentIncludes":[true],"toolCallsInclude":["is_invalid_token_error"],"must":["Prefer the structural typed code when present."],"mustNot":["Require the legacy message when the typed code is available."],"humanReviewRequired":false},"tags":["auth","errors"],"mock":{}} +{"id":"auth-errors.detect-code-case-insensitive","suite":"auth-errors","executor":"relay","kind":"regression","input":{"message":"Detect the invalid-token typed code regardless of case or surrounding whitespace.","operation":[{"op":"is_invalid_token_error","error":{"code":" AGENT_TOKEN_INVALID "}}]},"expected":{"ok":true,"contentIncludes":[true],"must":["Trim and lowercase the code before matching."],"mustNot":["Miss uppercase agent token invalid codes."],"humanReviewRequired":false},"tags":["auth","errors"],"mock":{}} +{"id":"auth-errors.detect-legacy-status-message","suite":"auth-errors","executor":"relay","kind":"regression","input":{"message":"Detect the legacy invalid-token contract from a 401 status and canonical message.","operation":[{"op":"is_invalid_token_error","error":{"statusCode":401,"message":"Invalid agent token"}}]},"expected":{"ok":true,"contentIncludes":[true],"must":["Continue to recognize the legacy 401 plus canonical message pair."],"mustNot":["Require an error code for legacy Relaycast responses."],"humanReviewRequired":false},"tags":["auth","errors","legacy"],"mock":{}} +{"id":"auth-errors.detect-body-error-code","suite":"auth-errors","executor":"relay","kind":"capability","input":{"message":"Detect an invalid-token code nested inside a body.error envelope.","operation":[{"op":"is_invalid_token_error","error":{"status":401,"message":"Unauthorized","body":{"error":{"code":"agent_token_invalid","message":"ignored"}}}}]},"expected":{"ok":true,"contentIncludes":[true],"must":["Inspect nested `body.error.code` values."],"mustNot":["Depend on the top-level error message when a body code is present."],"humanReviewRequired":false},"tags":["auth","errors","body"],"mock":{}} +{"id":"auth-errors.detect-cause-chain","suite":"auth-errors","executor":"relay","kind":"regression","input":{"message":"Detect an invalid token marker nested in an error cause chain.","operation":[{"op":"is_invalid_token_error","error":{"message":"upstream call failed","cause":{"statusCode":401,"message":"Invalid agent token"}}}]},"expected":{"ok":true,"contentIncludes":[true],"must":["Recognize invalid-token errors wrapped by upstream failure errors."],"mustNot":["Treat the wrapper message as authoritative when a cause is available."],"humanReviewRequired":false},"tags":["auth","errors","cause"],"mock":{}} +{"id":"auth-errors.ignore-non-token-unauthorized","suite":"auth-errors","executor":"relay","kind":"regression","input":{"message":"Reject a generic unauthorized error that is not the invalid agent token contract.","operation":[{"op":"is_invalid_token_error","error":{"statusCode":401,"message":"Unauthorized"}}]},"expected":{"ok":true,"contentIncludes":[false],"must":["Return false for 401 errors without the canonical invalid-token message or code."],"mustNot":["Clear tokens for unrelated unauthorized errors."],"humanReviewRequired":false},"tags":["auth","errors","false-positive"],"mock":{}} +{"id":"auth-errors.detect-tool-result-content","suite":"auth-errors","executor":"relay","kind":"capability","input":{"message":"Detect an invalid-token marker inside a tool result content array.","operation":[{"op":"is_invalid_token_tool_result","result":{"content":[{"type":"text","text":"noise"},{"type":"text","text":"Invalid agent token"}]}}]},"expected":{"ok":true,"contentIncludes":[true],"toolCallsInclude":["is_invalid_token_tool_result"],"must":["Search all text entries in a tool result content array."],"mustNot":["Require the tool result to set `isError`."],"humanReviewRequired":false},"tags":["auth","tool-results"],"mock":{}} +{"id":"auth-errors.ignore-tool-result-without-marker","suite":"auth-errors","executor":"relay","kind":"regression","input":{"message":"Reject a tool result whose content does not include the invalid-token marker.","operation":[{"op":"is_invalid_token_tool_result","result":{"content":[{"type":"text","text":"all good"}]}}]},"expected":{"ok":true,"contentIncludes":[false],"must":["Avoid false positives on ordinary text tool results."],"mustNot":["Treat any text error as an invalid agent token."],"humanReviewRequired":false},"tags":["auth","tool-results","false-positive"],"mock":{}} +{"id":"auth-errors.recovery-message-guidance","suite":"auth-errors","executor":"relay","kind":"capability","input":{"message":"Build the user-facing recovery message for a stale agent token.","operation":[{"op":"token_recovery_message"}]},"expected":{"ok":true,"contentIncludes":["agent_token_invalid","selected Relaycast agent token is no longer valid","stale token was cleared","register_agent"],"toolCallsInclude":["token_recovery_message"],"must":["Tell the caller that the stale token was cleared.","Name the `register_agent` tool as the recovery action."],"mustNot":["Ask the caller to retry with the same stale token."],"humanReviewRequired":false},"tags":["auth","recovery"],"mock":{}} diff --git a/evals/suites/auth-errors/cases.md b/evals/suites/auth-errors/cases.md new file mode 100644 index 000000000..743bf0941 --- /dev/null +++ b/evals/suites/auth-errors/cases.md @@ -0,0 +1,346 @@ +# Auth Error Cases + +These cases pin invalid Relaycast agent-token detection and recovery messaging +so MCP and SDK callers can recover from stale agent credentials deterministically. + +## auth-errors.detect-top-level-code +Executor: relay +Kind: capability +Tags: auth, errors +Human Review: false + +### Message +Detect an invalid agent token from a top-level typed error code. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "is_invalid_token_error", "error": { "code": "agent_token_invalid", "message": "whatever" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- true +toolCallsInclude: +- is_invalid_token_error + +### Must +- Prefer the structural typed code when present. + +### Must Not +- Require the legacy message when the typed code is available. + +## auth-errors.detect-code-case-insensitive +Executor: relay +Kind: regression +Tags: auth, errors +Human Review: false + +### Message +Detect the invalid-token typed code regardless of case or surrounding whitespace. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "is_invalid_token_error", "error": { "code": " AGENT_TOKEN_INVALID " } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- true +must: +- Normalize error codes before comparison. +mustNot: +- Treat casing as significant. + +### Must +- Trim and lowercase the code before matching. + +### Must Not +- Miss uppercase agent token invalid codes. + +## auth-errors.detect-legacy-status-message +Executor: relay +Kind: regression +Tags: auth, errors, legacy +Human Review: false + +### Message +Detect the legacy invalid-token contract from a 401 status and canonical message. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "is_invalid_token_error", "error": { "statusCode": 401, "message": "Invalid agent token" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- true + +### Must +- Continue to recognize the legacy 401 plus canonical message pair. + +### Must Not +- Require an error code for legacy Relaycast responses. + +## auth-errors.detect-body-error-code +Executor: relay +Kind: capability +Tags: auth, errors, body +Human Review: false + +### Message +Detect an invalid-token code nested inside a body.error envelope. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { + "op": "is_invalid_token_error", + "error": { + "status": 401, + "message": "Unauthorized", + "body": { "error": { "code": "agent_token_invalid", "message": "ignored" } } + } + } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- true + +### Must +- Inspect nested `body.error.code` values. + +### Must Not +- Depend on the top-level error message when a body code is present. + +## auth-errors.detect-cause-chain +Executor: relay +Kind: regression +Tags: auth, errors, cause +Human Review: false + +### Message +Detect an invalid token marker nested in an error cause chain. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { + "op": "is_invalid_token_error", + "error": { + "message": "upstream call failed", + "cause": { "statusCode": 401, "message": "Invalid agent token" } + } + } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- true +must: +- Walk nested cause objects until a marker is found. +mustNot: +- Stop at the first non-matching wrapper error. + +### Must +- Recognize invalid-token errors wrapped by upstream failure errors. + +### Must Not +- Treat the wrapper message as authoritative when a cause is available. + +## auth-errors.ignore-non-token-unauthorized +Executor: relay +Kind: regression +Tags: auth, errors, false-positive +Human Review: false + +### Message +Reject a generic unauthorized error that is not the invalid agent token contract. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "is_invalid_token_error", "error": { "statusCode": 401, "message": "Unauthorized" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- false +must: +- Distinguish generic auth failures from stale agent-token failures. +mustNot: +- Include agent_token_invalid + +### Must +- Return false for 401 errors without the canonical invalid-token message or code. + +### Must Not +- Clear tokens for unrelated unauthorized errors. + +## auth-errors.detect-tool-result-content +Executor: relay +Kind: capability +Tags: auth, tool-results +Human Review: false + +### Message +Detect an invalid-token marker inside a tool result content array. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { + "op": "is_invalid_token_tool_result", + "result": { + "content": [ + { "type": "text", "text": "noise" }, + { "type": "text", "text": "Invalid agent token" } + ] + } + } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- true +toolCallsInclude: +- is_invalid_token_tool_result + +### Must +- Search all text entries in a tool result content array. + +### Must Not +- Require the tool result to set `isError`. + +## auth-errors.ignore-tool-result-without-marker +Executor: relay +Kind: regression +Tags: auth, tool-results, false-positive +Human Review: false + +### Message +Reject a tool result whose content does not include the invalid-token marker. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { + "op": "is_invalid_token_tool_result", + "result": { + "content": [ + { "type": "text", "text": "all good" } + ] + } + } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- false +must: +- Return false when no text content equals the canonical marker. +mustNot: +- Include agent_token_invalid + +### Must +- Avoid false positives on ordinary text tool results. + +### Must Not +- Treat any text error as an invalid agent token. + +## auth-errors.recovery-message-guidance +Executor: relay +Kind: capability +Tags: auth, recovery +Human Review: false + +### Message +Build the user-facing recovery message for a stale agent token. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "token_recovery_message" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- agent_token_invalid +- selected Relaycast agent token is no longer valid +- stale token was cleared +- register_agent +toolCallsInclude: +- token_recovery_message + +### Must +- Tell the caller that the stale token was cleared. +- Name the `register_agent` tool as the recovery action. + +### Must Not +- Ask the caller to retry with the same stale token. diff --git a/evals/suites/auth-errors/rubric.md b/evals/suites/auth-errors/rubric.md new file mode 100644 index 000000000..8ebf880c7 --- /dev/null +++ b/evals/suites/auth-errors/rubric.md @@ -0,0 +1,8 @@ +# Auth Errors Rubric + +Auth error cases are deterministic. A passing run must show that invalid +Relaycast agent tokens are detected by typed code, legacy status/message pairs, +nested body errors, cause chains, and MCP-style tool-result content, while +ordinary unauthorized errors and unrelated tool results do not trigger token +recovery. The recovery message must include the stable code and explicit +`register_agent` guidance. diff --git a/evals/suites/capabilities/cases.jsonl b/evals/suites/capabilities/cases.jsonl new file mode 100644 index 000000000..6a73b2dc0 --- /dev/null +++ b/evals/suites/capabilities/cases.jsonl @@ -0,0 +1,8 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"capabilities.delivery-runner-requires-server-state","suite":"capabilities","executor":"relay","kind":"regression","input":{"message":"DeliveryRunner should refuse to start without server-backed delivery state support.","operation":[{"op":"deliver","as":"worker","mode":"wait","reason":"message"}]},"expected":{"must":["Throw before connecting the delivery adapter.","Expose the missing capability name."],"mustNot":["Inject or ack messages when durable delivery state is unavailable."],"humanReviewRequired":true},"tags":["capabilities","delivery","error","pending-executor"],"mock":{"agents":[{"name":"worker","type":"agent"}],"inbox":[{"id":"in_cap_1","recipient":"worker","from":"lead","text":"Cannot durable ack"}],"delivery":{"target":"worker","serverDeliveryState":false,"result":{"status":"delivered"}}}} +{"id":"capabilities.delivery-unsupported-does-not-inject","suite":"capabilities","executor":"relay","kind":"regression","input":{"message":"Unsupported delivery state should stop the delivery operation before any inbox item is consumed.","operation":[{"op":"deliver","as":"worker","mode":"steer","reason":"mention"}]},"expected":{"must":["Fail fast before processing `in_cap_2`.","Leave both inbox items unacknowledged."],"mustNot":["Partially process queued items after capability failure."],"humanReviewRequired":true},"tags":["capabilities","delivery","guard","pending-executor"],"mock":{"agents":[{"name":"worker","type":"agent"}],"inbox":[{"id":"in_cap_2","recipient":"worker","from":"lead","text":"First"},{"id":"in_cap_3","recipient":"worker","from":"lead","text":"Second"}],"delivery":{"target":"worker","serverDeliveryState":false,"result":{"status":"delivered"}}}} +{"id":"capabilities.missing-agent-client-send-message","suite":"capabilities","executor":"relay","kind":"capability","input":{"message":"Agent-scoped message operations require an agent client capability.","operation":[{"op":"post_message","as":"observer","channel":"general","text":"Should require an agent client","id":"msg_cap_1"}]},"expected":{"must":["Surface a RelayCapabilityError for the missing agent-scoped operation."],"mustNot":["Create `msg_cap_1` without an agent client."],"humanReviewRequired":true},"tags":["capabilities","messaging","agent-client","pending-executor"],"mock":{"agents":[{"name":"observer","type":"agent"}],"channels":[{"name":"general","members":["observer"]}],"clientCapabilities":{"agentClient":false}}} +{"id":"capabilities.missing-agent-client-channel-join","suite":"capabilities","executor":"relay","kind":"capability","input":{"message":"Channel membership mutations require an agent-scoped client capability.","operation":[{"op":"join_channel","as":"observer","channel":"general"}]},"expected":{"must":["Reject join_channel when the client lacks the required agent client."],"mustNot":["Add observer to the channel after capability failure."],"humanReviewRequired":true},"tags":["capabilities","channels","agent-client","pending-executor"],"mock":{"agents":[{"name":"observer","type":"agent"}],"channels":[{"name":"general","members":[]}],"clientCapabilities":{"agentClient":false}}} +{"id":"capabilities.events-subscribe-requires-agent-client","suite":"capabilities","executor":"relay","kind":"capability","input":{"message":"Event subscription should require the events.subscribe agent-client capability.","operation":[{"op":"add_listener","as":"observer","selector":{"type":"messageCreated","channel":"general"}}]},"expected":{"must":["Report a missing event subscription capability."],"mustNot":["Register a live listener when event transport is unavailable."],"humanReviewRequired":true},"tags":["capabilities","events","pending-executor"],"mock":{"agents":[{"name":"observer","type":"agent"}],"channels":[{"name":"general","members":["observer"]}],"clientCapabilities":{"agentClient":false}}} +{"id":"capabilities.unsupported-durable-ack-stub","suite":"capabilities","executor":"relay","kind":"regression","input":{"message":"Unsupported durable delivery ack should return an explicit unsupported result rather than pretending success.","operation":[{"op":"mark_read","as":"observer","messageId":"msg_cap_ack"}]},"expected":{"must":["Return an explicit unsupported capability result."],"mustNot":["Report durable ack support when the mock disables it."],"humanReviewRequired":true},"tags":["capabilities","durable-delivery","stub","pending-executor"],"mock":{"agents":[{"name":"observer","type":"agent"}],"messages":[{"id":"msg_cap_ack","channel":"general","from":"lead","text":"Ack me"}],"deliveryCapabilities":{"durableAck":false}}} diff --git a/evals/suites/capabilities/cases.md b/evals/suites/capabilities/cases.md new file mode 100644 index 000000000..8ee47c0a7 --- /dev/null +++ b/evals/suites/capabilities/cases.md @@ -0,0 +1,207 @@ +# Capabilities Cases +Capability cases verify RelayCapabilityError paths and unsupported capability reporting for delivery and agent-scoped relay operations. + +## capabilities.delivery-runner-requires-server-state +Executor: relay +Kind: regression +Tags: capabilities, delivery, error, pending-executor +Human Review: true + +### Message +DeliveryRunner should refuse to start without server-backed delivery state support. + +### Mock +```json +{ + "agents": [{ "name": "worker", "type": "agent" }], + "inbox": [ + { "id": "in_cap_1", "recipient": "worker", "from": "lead", "text": "Cannot durable ack" } + ], + "delivery": { + "target": "worker", + "serverDeliveryState": false, + "result": { "status": "delivered" } + } +} +``` + +### Operations +```json +[ + { "op": "deliver", "as": "worker", "mode": "wait", "reason": "message" } +] +``` + +### Deterministic Checks +must: +- Throw before connecting the delivery adapter. +- Expose the missing capability name. +mustNot: +- Inject or ack messages when durable delivery state is unavailable. + +## capabilities.delivery-unsupported-does-not-inject +Executor: relay +Kind: regression +Tags: capabilities, delivery, guard, pending-executor +Human Review: true + +### Message +Unsupported delivery state should stop the delivery operation before any inbox item is consumed. + +### Mock +```json +{ + "agents": [{ "name": "worker", "type": "agent" }], + "inbox": [ + { "id": "in_cap_2", "recipient": "worker", "from": "lead", "text": "First" }, + { "id": "in_cap_3", "recipient": "worker", "from": "lead", "text": "Second" } + ], + "delivery": { + "target": "worker", + "serverDeliveryState": false, + "result": { "status": "delivered" } + } +} +``` + +### Operations +```json +[ + { "op": "deliver", "as": "worker", "mode": "steer", "reason": "mention" } +] +``` + +### Deterministic Checks +must: +- Fail fast before processing `in_cap_2`. +- Leave both inbox items unacknowledged. +mustNot: +- Partially process queued items after capability failure. + +## capabilities.missing-agent-client-send-message +Executor: relay +Kind: capability +Tags: capabilities, messaging, agent-client, pending-executor +Human Review: true + +### Message +Agent-scoped message operations require an agent client capability. + +### Mock +```json +{ + "agents": [{ "name": "observer", "type": "agent" }], + "channels": [{ "name": "general", "members": ["observer"] }], + "clientCapabilities": { "agentClient": false } +} +``` + +### Operations +```json +[ + { "op": "post_message", "as": "observer", "channel": "general", "text": "Should require an agent client", "id": "msg_cap_1" } +] +``` + +### Deterministic Checks +must: +- Surface a RelayCapabilityError for the missing agent-scoped operation. +mustNot: +- Create `msg_cap_1` without an agent client. + +## capabilities.missing-agent-client-channel-join +Executor: relay +Kind: capability +Tags: capabilities, channels, agent-client, pending-executor +Human Review: true + +### Message +Channel membership mutations require an agent-scoped client capability. + +### Mock +```json +{ + "agents": [{ "name": "observer", "type": "agent" }], + "channels": [{ "name": "general", "members": [] }], + "clientCapabilities": { "agentClient": false } +} +``` + +### Operations +```json +[ + { "op": "join_channel", "as": "observer", "channel": "general" } +] +``` + +### Deterministic Checks +must: +- Reject join_channel when the client lacks the required agent client. +mustNot: +- Add observer to the channel after capability failure. + +## capabilities.events-subscribe-requires-agent-client +Executor: relay +Kind: capability +Tags: capabilities, events, pending-executor +Human Review: true + +### Message +Event subscription should require the events.subscribe agent-client capability. + +### Mock +```json +{ + "agents": [{ "name": "observer", "type": "agent" }], + "channels": [{ "name": "general", "members": ["observer"] }], + "clientCapabilities": { "agentClient": false } +} +``` + +### Operations +```json +[ + { "op": "add_listener", "as": "observer", "selector": { "type": "messageCreated", "channel": "general" } } +] +``` + +### Deterministic Checks +must: +- Report a missing event subscription capability. +mustNot: +- Register a live listener when event transport is unavailable. + +## capabilities.unsupported-durable-ack-stub +Executor: relay +Kind: regression +Tags: capabilities, durable-delivery, stub, pending-executor +Human Review: true + +### Message +Unsupported durable delivery ack should return an explicit unsupported result rather than pretending success. + +### Mock +```json +{ + "agents": [{ "name": "observer", "type": "agent" }], + "messages": [ + { "id": "msg_cap_ack", "channel": "general", "from": "lead", "text": "Ack me" } + ], + "deliveryCapabilities": { + "durableAck": false + } +} +``` + +### Operations +```json +[ + { "op": "mark_read", "as": "observer", "messageId": "msg_cap_ack" } +] +``` + +### Deterministic Checks +must: +- Return an explicit unsupported capability result. +mustNot: +- Report durable ack support when the mock disables it. diff --git a/evals/suites/capabilities/rubric.md b/evals/suites/capabilities/rubric.md new file mode 100644 index 000000000..bfe0735fb --- /dev/null +++ b/evals/suites/capabilities/rubric.md @@ -0,0 +1,5 @@ +# Capabilities Rubric + +Capability cases pass when missing SDK capabilities are surfaced explicitly and early. DeliveryRunner must throw `RelayCapabilityError` with capability `messaging.capabilities.serverDeliveryState` before delivery side effects when durable server state is unavailable. Agent-scoped messaging, channel, and event operations should report RelayCapabilityError when the mock client lacks an agent client. Unsupported durable-delivery stubs should return clear unsupported results without mutating state or claiming success. + +Cases tagged `pending-executor` preserve intended capability coverage while the in-memory executor learns those capability hooks; once supported, restore deterministic checks for the RelayCapabilityError and unsupported-result assertions. diff --git a/evals/suites/channels/cases.jsonl b/evals/suites/channels/cases.jsonl new file mode 100644 index 000000000..7eb735266 --- /dev/null +++ b/evals/suites/channels/cases.jsonl @@ -0,0 +1,13 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"channels.create-with-topic","suite":"channels","executor":"relay","kind":"capability","input":{"message":"Create a planning channel with an initial topic and verify it appears in channel listings.","operation":[{"op":"create_channel","as":"Lead","name":"launch-room","topic":"Launch coordination"},{"op":"list_channels","as":"Lead"}]},"expected":{"ok":true,"contentIncludes":["launch-room","Launch coordination"],"toolCallsInclude":["create_channel","list_channels"],"minToolCalls":2,"must":["Persist the created channel with its requested topic.","Return the channel from an ordinary channel listing."],"mustNot":["Drop the topic during create normalization."],"humanReviewRequired":false},"tags":["channels","create","topic"],"mock":{"agents":[{"name":"Lead","type":"human"}]}} +{"id":"channels.create-duplicate-name-rejected","suite":"channels","executor":"relay","kind":"regression","input":{"message":"Creating a channel with an existing name should fail without changing existing membership.","operation":[{"op":"create_channel","as":"Lead","name":"general","topic":"Duplicate room"}]},"expected":{"ok":false,"errorCode":["channel_exists"],"channelMembers":[{"channel":"general","members":["Lead","WorkerA"]}],"must":["Reject duplicate channel names deterministically."],"mustNot":["Replace the existing channel topic or membership."],"humanReviewRequired":false},"tags":["channels","create","errors"],"mock":{"agents":[{"name":"Lead","type":"human"},{"name":"WorkerA","type":"agent"}],"channels":[{"name":"general","topic":"Default room","members":["Lead","WorkerA"]}]}} +{"id":"channels.join-existing-channel","suite":"channels","executor":"relay","kind":"capability","input":{"message":"A registered worker joins an existing project channel.","operation":[{"op":"join_channel","as":"WorkerA","channel":"project-alpha"},{"op":"list_channels","as":"WorkerA"}]},"expected":{"ok":true,"contentIncludes":["project-alpha","WorkerA"],"channelMembers":[{"channel":"project-alpha","members":["Lead","WorkerA"]}],"toolCallsInclude":["join_channel"],"must":["Add the joining agent to the channel membership set.","Keep pre-existing members in the channel."],"mustNot":["Create a duplicate membership row for the joining agent."],"humanReviewRequired":false},"tags":["channels","membership","join"],"mock":{"agents":[{"name":"Lead","type":"human"},{"name":"WorkerA","type":"agent"}],"channels":[{"name":"project-alpha","topic":"Alpha work","members":["Lead"]}]}} +{"id":"channels.join-idempotent-for-member","suite":"channels","executor":"relay","kind":"regression","input":{"message":"Joining a channel twice should leave membership stable.","operation":[{"op":"join_channel","as":"WorkerA","channel":"standup"},{"op":"join_channel","as":"WorkerA","channel":"standup"},{"op":"list_channels","as":"WorkerA"}]},"expected":{"ok":true,"contentIncludes":["standup"],"channelMembers":[{"channel":"standup","members":["WorkerA"]}],"minToolCalls":3,"must":["Treat repeated joins by the same agent as idempotent."],"mustNot":["Add duplicate copies of the same agent to the member list."],"humanReviewRequired":false},"tags":["channels","membership","idempotency"],"mock":{"agents":[{"name":"WorkerA","type":"agent"}],"channels":[{"name":"standup","topic":"Daily updates","members":["WorkerA"]}]}} +{"id":"channels.leave-removes-membership","suite":"channels","executor":"relay","kind":"capability","input":{"message":"A worker leaves a channel and should no longer appear as a member.","operation":[{"op":"leave_channel","as":"WorkerA","channel":"handoff"},{"op":"list_channels","as":"Lead"}]},"expected":{"ok":true,"contentIncludes":["handoff"],"forbidPhrases":["WorkerA"],"channelMembers":[{"channel":"handoff","members":["Lead"]}],"must":["Remove only the leaving agent from the channel.","Preserve the channel and its remaining members."],"mustNot":["Delete the channel when one member leaves."],"humanReviewRequired":false},"tags":["channels","membership","leave"],"mock":{"agents":[{"name":"Lead","type":"human"},{"name":"WorkerA","type":"agent"}],"channels":[{"name":"handoff","topic":"Handoff queue","members":["Lead","WorkerA"]}]}} +{"id":"channels.invite-adds-target-member","suite":"channels","executor":"relay","kind":"capability","input":{"message":"An existing channel member invites another registered agent into the channel.","operation":[{"op":"invite_to_channel","as":"Lead","channel":"triage","agent":"WorkerB"},{"op":"list_channels","as":"WorkerB"}]},"expected":{"ok":true,"contentIncludes":["triage"],"channelMembers":[{"channel":"triage","members":["Lead","WorkerA","WorkerB"]}],"must":["Add the invited agent to the channel members.","Keep the inviter and existing members in place."],"mustNot":["Require the invited agent to call join before membership is visible."],"humanReviewRequired":false},"tags":["channels","membership","invite"],"mock":{"agents":[{"name":"Lead","type":"human"},{"name":"WorkerA","type":"agent"},{"name":"WorkerB","type":"agent"}],"channels":[{"name":"triage","topic":"Incoming work","members":["Lead","WorkerA"]}]}} +{"id":"channels.invite-unknown-agent-rejected","suite":"channels","executor":"relay","kind":"regression","input":{"message":"Inviting an agent name that is not registered should fail without changing channel members.","operation":[{"op":"invite_to_channel","as":"Lead","channel":"ops","agent":"MissingWorker"}]},"expected":{"ok":false,"errorCode":["agent_not_found"],"channelMembers":[{"channel":"ops","members":["Lead"]}],"must":["Return a deterministic not-found error for unknown invite targets."],"mustNot":["Create placeholder agent records as a side effect of invite."],"humanReviewRequired":false},"tags":["channels","membership","errors"],"mock":{"agents":[{"name":"Lead","type":"human"}],"channels":[{"name":"ops","topic":"Operations","members":["Lead"]}]}} +{"id":"channels.set-topic-updates-state","suite":"channels","executor":"relay","kind":"capability","input":{"message":"Update a channel topic and verify the new topic appears in channel listings.","operation":[{"op":"set_topic","as":"Lead","channel":"planning","topic":"Release readiness"},{"op":"list_channels","as":"Lead"}]},"expected":{"ok":true,"contentIncludes":["planning","Release readiness"],"forbidPhrases":["Old topic"],"toolCallsInclude":["set_topic"],"must":["Persist the exact replacement topic on the channel."],"mustNot":["Append the new topic to the old topic text."],"humanReviewRequired":false},"tags":["channels","topic"],"mock":{"agents":[{"name":"Lead","type":"human"}],"channels":[{"name":"planning","topic":"Old topic","members":["Lead"]}]}} +{"id":"channels.archive-hides-from-default-list","suite":"channels","executor":"relay","kind":"regression","input":{"message":"Archiving a channel should remove it from the default channel listing.","operation":[{"op":"archive_channel","as":"Lead","channel":"old-room"},{"op":"list_channels","as":"Lead"}]},"expected":{"ok":true,"contentIncludes":["active-room"],"forbidPhrases":["old-room"],"toolCallsInclude":["archive_channel"],"must":["Hide archived channels from default listings.","Keep unrelated active channels visible."],"mustNot":["Delete or hide unrelated channels when one channel is archived."],"humanReviewRequired":false},"tags":["channels","archive"],"mock":{"agents":[{"name":"Lead","type":"human"}],"channels":[{"name":"old-room","topic":"Past work","members":["Lead"]},{"name":"active-room","topic":"Current work","members":["Lead"]}]}} +{"id":"channels.archive-visible-when-included","suite":"channels","executor":"relay","kind":"capability","input":{"message":"Archived channels should be visible when the caller explicitly includes archived channels.","operation":[{"op":"archive_channel","as":"Lead","channel":"old-room"},{"op":"list_channels","as":"Lead","includeArchived":true}]},"expected":{"ok":true,"contentIncludes":["old-room","active-room","archived"],"channelMembers":[{"channel":"old-room","members":["Lead"]}],"must":["Retain archived channel state for include-archived listings.","Preserve archived channel membership for auditability."],"mustNot":["Permanently delete the archived channel record."],"humanReviewRequired":false},"tags":["channels","archive","list"],"mock":{"agents":[{"name":"Lead","type":"human"}],"channels":[{"name":"old-room","topic":"Past work","members":["Lead"]},{"name":"active-room","topic":"Current work","members":["Lead"]}]}} +{"id":"channels.join-archived-channel-rejected","suite":"channels","executor":"relay","kind":"regression","input":{"message":"A worker should not be able to join an archived channel.","operation":[{"op":"join_channel","as":"WorkerA","channel":"closed-room"}]},"expected":{"ok":false,"errorCode":["channel_archived"],"forbidPhrases":["WorkerA"],"channelMembers":[{"channel":"closed-room","members":["Lead"]}],"must":["Reject membership changes on archived channels."],"mustNot":["Reopen archived channels as a side effect of join."],"humanReviewRequired":false},"tags":["channels","archive","membership","errors"],"mock":{"agents":[{"name":"Lead","type":"human"},{"name":"WorkerA","type":"agent"}],"channels":[{"name":"closed-room","topic":"Done","members":["Lead"],"archived":true}]}} diff --git a/evals/suites/channels/cases.md b/evals/suites/channels/cases.md new file mode 100644 index 000000000..fa84d2636 --- /dev/null +++ b/evals/suites/channels/cases.md @@ -0,0 +1,486 @@ +# Channels Cases +Channels cases pin channel lifecycle behavior for the Relay SDK eval harness, including membership changes, topic updates, archive visibility, and duplicate-name errors. + +## channels.create-with-topic +Executor: relay +Kind: capability +Tags: channels, create, topic +Human Review: false + +### Message +Create a planning channel with an initial topic and verify it appears in channel listings. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" } + ] +} +``` + +### Operations +```json +[ + { "op": "create_channel", "as": "Lead", "name": "launch-room", "topic": "Launch coordination" }, + { "op": "list_channels", "as": "Lead" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- launch-room +- Launch coordination +toolCallsInclude: +- create_channel +- list_channels +minToolCalls: 2 + +### Must +- Persist the created channel with its requested topic. +- Return the channel from an ordinary channel listing. + +### Must Not +- Drop the topic during create normalization. + +## channels.create-duplicate-name-rejected +Executor: relay +Kind: regression +Tags: channels, create, errors +Human Review: false + +### Message +Creating a channel with an existing name should fail without changing existing membership. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" }, + { "name": "WorkerA", "type": "agent" } + ], + "channels": [ + { "name": "general", "topic": "Default room", "members": ["Lead", "WorkerA"] } + ] +} +``` + +### Operations +```json +[ + { "op": "create_channel", "as": "Lead", "name": "general", "topic": "Duplicate room" } +] +``` + +### Deterministic Checks +ok: false +errorCode: channel_exists +channelMembers: +- {"channel":"general","members":["Lead","WorkerA"]} +must: +- Reject duplicate channel names deterministically. +mustNot: +- Replace the existing channel topic or membership. + +## channels.join-existing-channel +Executor: relay +Kind: capability +Tags: channels, membership, join +Human Review: false + +### Message +A registered worker joins an existing project channel. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" }, + { "name": "WorkerA", "type": "agent" } + ], + "channels": [ + { "name": "project-alpha", "topic": "Alpha work", "members": ["Lead"] } + ] +} +``` + +### Operations +```json +[ + { "op": "join_channel", "as": "WorkerA", "channel": "project-alpha" }, + { "op": "list_channels", "as": "WorkerA" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- project-alpha +- WorkerA +channelMembers: +- {"channel":"project-alpha","members":["Lead","WorkerA"]} +toolCallsInclude: +- join_channel + +### Must +- Add the joining agent to the channel membership set. +- Keep pre-existing members in the channel. + +### Must Not +- Create a duplicate membership row for the joining agent. + +## channels.join-idempotent-for-member +Executor: relay +Kind: regression +Tags: channels, membership, idempotency +Human Review: false + +### Message +Joining a channel twice should leave membership stable. + +### Mock +```json +{ + "agents": [ + { "name": "WorkerA", "type": "agent" } + ], + "channels": [ + { "name": "standup", "topic": "Daily updates", "members": ["WorkerA"] } + ] +} +``` + +### Operations +```json +[ + { "op": "join_channel", "as": "WorkerA", "channel": "standup" }, + { "op": "join_channel", "as": "WorkerA", "channel": "standup" }, + { "op": "list_channels", "as": "WorkerA" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- standup +channelMembers: +- {"channel":"standup","members":["WorkerA"]} +minToolCalls: 3 + +### Must +- Treat repeated joins by the same agent as idempotent. + +### Must Not +- Add duplicate copies of the same agent to the member list. + +## channels.leave-removes-membership +Executor: relay +Kind: capability +Tags: channels, membership, leave +Human Review: false + +### Message +A worker leaves a channel and should no longer appear as a member. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" }, + { "name": "WorkerA", "type": "agent" } + ], + "channels": [ + { "name": "handoff", "topic": "Handoff queue", "members": ["Lead", "WorkerA"] } + ] +} +``` + +### Operations +```json +[ + { "op": "leave_channel", "as": "WorkerA", "channel": "handoff" }, + { "op": "list_channels", "as": "Lead" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- handoff +forbidPhrases: +- WorkerA +channelMembers: +- {"channel":"handoff","members":["Lead"]} + +### Must +- Remove only the leaving agent from the channel. +- Preserve the channel and its remaining members. + +### Must Not +- Delete the channel when one member leaves. + +## channels.invite-adds-target-member +Executor: relay +Kind: capability +Tags: channels, membership, invite +Human Review: false + +### Message +An existing channel member invites another registered agent into the channel. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" }, + { "name": "WorkerA", "type": "agent" }, + { "name": "WorkerB", "type": "agent" } + ], + "channels": [ + { "name": "triage", "topic": "Incoming work", "members": ["Lead", "WorkerA"] } + ] +} +``` + +### Operations +```json +[ + { "op": "invite_to_channel", "as": "Lead", "channel": "triage", "agent": "WorkerB" }, + { "op": "list_channels", "as": "WorkerB" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- triage +channelMembers: +- {"channel":"triage","members":["Lead","WorkerA","WorkerB"]} + +### Must +- Add the invited agent to the channel members. +- Keep the inviter and existing members in place. + +### Must Not +- Require the invited agent to call join before membership is visible. + +## channels.invite-unknown-agent-rejected +Executor: relay +Kind: regression +Tags: channels, membership, errors +Human Review: false + +### Message +Inviting an agent name that is not registered should fail without changing channel members. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" } + ], + "channels": [ + { "name": "ops", "topic": "Operations", "members": ["Lead"] } + ] +} +``` + +### Operations +```json +[ + { "op": "invite_to_channel", "as": "Lead", "channel": "ops", "agent": "MissingWorker" } +] +``` + +### Deterministic Checks +ok: false +errorCode: agent_not_found +channelMembers: +- {"channel":"ops","members":["Lead"]} + +### Must +- Return a deterministic not-found error for unknown invite targets. + +### Must Not +- Create placeholder agent records as a side effect of invite. + +## channels.set-topic-updates-state +Executor: relay +Kind: capability +Tags: channels, topic +Human Review: false + +### Message +Update a channel topic and verify the new topic appears in channel listings. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" } + ], + "channels": [ + { "name": "planning", "topic": "Old topic", "members": ["Lead"] } + ] +} +``` + +### Operations +```json +[ + { "op": "set_topic", "as": "Lead", "channel": "planning", "topic": "Release readiness" }, + { "op": "list_channels", "as": "Lead" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- planning +- Release readiness +forbidPhrases: +- Old topic +toolCallsInclude: +- set_topic + +### Must +- Persist the exact replacement topic on the channel. + +### Must Not +- Append the new topic to the old topic text. + +## channels.archive-hides-from-default-list +Executor: relay +Kind: regression +Tags: channels, archive +Human Review: false + +### Message +Archiving a channel should remove it from the default channel listing. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" } + ], + "channels": [ + { "name": "old-room", "topic": "Past work", "members": ["Lead"] }, + { "name": "active-room", "topic": "Current work", "members": ["Lead"] } + ] +} +``` + +### Operations +```json +[ + { "op": "archive_channel", "as": "Lead", "channel": "old-room" }, + { "op": "list_channels", "as": "Lead" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- active-room +forbidPhrases: +- old-room +toolCallsInclude: +- archive_channel + +### Must +- Hide archived channels from default listings. +- Keep unrelated active channels visible. + +### Must Not +- Delete or hide unrelated channels when one channel is archived. + +## channels.archive-visible-when-included +Executor: relay +Kind: capability +Tags: channels, archive, list +Human Review: false + +### Message +Archived channels should be visible when the caller explicitly includes archived channels. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" } + ], + "channels": [ + { "name": "old-room", "topic": "Past work", "members": ["Lead"] }, + { "name": "active-room", "topic": "Current work", "members": ["Lead"] } + ] +} +``` + +### Operations +```json +[ + { "op": "archive_channel", "as": "Lead", "channel": "old-room" }, + { "op": "list_channels", "as": "Lead", "includeArchived": true } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- old-room +- active-room +- archived +channelMembers: +- {"channel":"old-room","members":["Lead"]} + +### Must +- Retain archived channel state for include-archived listings. +- Preserve archived channel membership for auditability. + +### Must Not +- Permanently delete the archived channel record. + +## channels.join-archived-channel-rejected +Executor: relay +Kind: regression +Tags: channels, archive, membership, errors +Human Review: false + +### Message +A worker should not be able to join an archived channel. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" }, + { "name": "WorkerA", "type": "agent" } + ], + "channels": [ + { "name": "closed-room", "topic": "Done", "members": ["Lead"], "archived": true } + ] +} +``` + +### Operations +```json +[ + { "op": "join_channel", "as": "WorkerA", "channel": "closed-room" } +] +``` + +### Deterministic Checks +ok: false +errorCode: channel_archived +forbidPhrases: +- WorkerA +channelMembers: +- {"channel":"closed-room","members":["Lead"]} + +### Must +- Reject membership changes on archived channels. + +### Must Not +- Reopen archived channels as a side effect of join. diff --git a/evals/suites/channels/rubric.md b/evals/suites/channels/rubric.md new file mode 100644 index 000000000..9055dbaa9 --- /dev/null +++ b/evals/suites/channels/rubric.md @@ -0,0 +1,5 @@ +# Channels Rubric + +Channels cases pass when the in-memory Relay executor preserves channel lifecycle state across create, join, leave, invite, topic, archive, and listing operations. + +Passing behavior must show deterministic membership snapshots, stable topic values, archived-channel visibility only when requested, and typed errors for duplicate channel names, unknown invite targets, and archived-channel membership attempts. diff --git a/evals/suites/delivery-modes/cases.jsonl b/evals/suites/delivery-modes/cases.jsonl new file mode 100644 index 000000000..dc2be447f --- /dev/null +++ b/evals/suites/delivery-modes/cases.jsonl @@ -0,0 +1,9 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"delivery-modes.wait-acks-delivered","suite":"delivery-modes","executor":"relay","kind":"regression","input":{"message":"A queued inbox item is delivered in wait mode and the runner should ack it as delivered.","operation":[{"op":"deliver","as":"worker","mode":"wait","reason":"message"}]},"expected":{"ok":true,"contentIncludes":["delivered"],"toolCallsInclude":["deliver"],"minToolCalls":1,"must":["Ack inbox item `in_wait_1` with delivered state.","Preserve delivery result metadata on the ack."],"mustNot":["Mark the item failed or deferred."],"humanReviewRequired":false},"tags":["delivery","wait","ack"],"mock":{"agents":[{"name":"worker","type":"agent"}],"inbox":[{"id":"in_wait_1","recipient":"worker","from":"lead","text":"Please review the patch"}],"delivery":{"target":"worker","serverDeliveryState":true,"result":{"status":"delivered","metadata":{"injected":true}}}}} +{"id":"delivery-modes.steer-interrupts-immediately","suite":"delivery-modes","executor":"relay","kind":"capability","input":{"message":"A steer-mode delivery should use an immediate/interrupt injection path for the active agent.","operation":[{"op":"deliver","as":"navigator","mode":"steer","reason":"mention","priority":"urgent"}]},"expected":{"ok":true,"contentIncludes":["accepted","interrupt"],"toolCallsInclude":["deliver"],"must":["Pass steer intent to the delivery adapter as an interrupt-style context.","Ack the accepted item without scheduling a retry."],"mustNot":["Treat steer mode as an idle wait."],"humanReviewRequired":false},"tags":["delivery","steer","interrupt"],"mock":{"agents":[{"name":"navigator","type":"agent"}],"inbox":[{"id":"in_steer_1","recipient":"navigator","from":"lead","text":"Stop and inspect the failing test now"}],"delivery":{"target":"navigator","serverDeliveryState":true,"result":{"status":"accepted","metadata":{"mode":"interrupt"}}}}} +{"id":"delivery-modes.deferred-result-schedules-availability","suite":"delivery-modes","executor":"relay","kind":"regression","input":{"message":"When the adapter defers a message, the runner should defer the inbox item until the supplied availability time.","operation":[{"op":"deliver","as":"worker","mode":"wait","reason":"message"}]},"expected":{"ok":true,"contentIncludes":["deferred","2026-05-27T11:00:00.000Z","busy"],"must":["Defer inbox item `in_defer_1` with the adapter-provided availability timestamp.","Preserve defer metadata."],"mustNot":["Ack a deferred item as delivered.","Mark a deferred item retryable failure."],"humanReviewRequired":false},"tags":["delivery","defer","backoff"],"mock":{"agents":[{"name":"worker","type":"agent"}],"inbox":[{"id":"in_defer_1","recipient":"worker","from":"lead","text":"Handle this after the current task"}],"delivery":{"target":"worker","serverDeliveryState":true,"result":{"status":"deferred","availableAt":"2026-05-27T11:00:00.000Z","reason":"busy","metadata":{"queue":"runtime"}}}}} +{"id":"delivery-modes.failed-result-terminal","suite":"delivery-modes","executor":"relay","kind":"regression","input":{"message":"An adapter-reported failed result should become a terminal non-retryable inbox failure.","operation":[{"op":"deliver","as":"worker","mode":"wait","reason":"message"}]},"expected":{"ok":true,"contentIncludes":["failed","runtime rejected message"],"must":["Fail inbox item `in_fail_1` with retry set to false.","Include terminal failure metadata."],"mustNot":["Retry adapter-reported failed results."],"humanReviewRequired":false},"tags":["delivery","failure","terminal"],"mock":{"agents":[{"name":"worker","type":"agent"}],"inbox":[{"id":"in_fail_1","recipient":"worker","from":"lead","text":"Deliver to unavailable runtime"}],"delivery":{"target":"worker","serverDeliveryState":true,"result":{"status":"failed","reason":"runtime rejected message","metadata":{"terminal":true}}}}} +{"id":"delivery-modes.thrown-error-retryable","suite":"delivery-modes","executor":"relay","kind":"regression","input":{"message":"If injection throws, DeliveryRunner should record a retryable failure instead of losing the inbox item.","operation":[{"op":"deliver","as":"worker","mode":"wait","reason":"message"}]},"expected":{"ok":true,"contentIncludes":["adapter unavailable"],"must":["Record a retryable failure for `in_retry_1`.","Invoke the delivery error hook before failing the item."],"mustNot":["Ack an item whose adapter injection threw."],"humanReviewRequired":false},"tags":["delivery","retry","error"],"mock":{"agents":[{"name":"worker","type":"agent"}],"inbox":[{"id":"in_retry_1","recipient":"worker","from":"lead","text":"Adapter will throw"}],"delivery":{"target":"worker","serverDeliveryState":true,"throws":"adapter unavailable"}}} +{"id":"delivery-modes.orders-multiple-inbox-items","suite":"delivery-modes","executor":"relay","kind":"capability","input":{"message":"Multiple queued inbox items should be delivered in subscription order.","operation":[{"op":"deliver","as":"worker","mode":"wait","reason":"message"}]},"expected":{"ok":true,"contentIncludes":["delivered"],"must":["Inject `in_order_1` before `in_order_2`.","Ack both items after successful delivery."],"mustNot":["Reorder queued inbox items."],"humanReviewRequired":false},"tags":["delivery","ordering"],"mock":{"agents":[{"name":"worker","type":"agent"}],"inbox":[{"id":"in_order_1","recipient":"worker","from":"lead","text":"First item"},{"id":"in_order_2","recipient":"worker","from":"lead","text":"Second item"}],"delivery":{"target":"worker","serverDeliveryState":true,"result":{"status":"delivered"}}}} +{"id":"delivery-modes.session-receive-message-contract","suite":"delivery-modes","executor":"relay","kind":"regression","input":{"message":"A delivery target with receiveMessage should receive message-mode context rather than adapter injection context.","operation":[{"op":"deliver","as":"session-worker","mode":"wait","reason":"mention","idempotencyKey":"idem-session-1"}]},"expected":{"ok":true,"contentIncludes":["del_session_1"],"must":["Call receiveMessage with a message context and deterministic delivery id.","Ack the inbox item after a delivered receipt."],"mustNot":["Require an inject method when receiveMessage is present."],"humanReviewRequired":false},"tags":["delivery","session"],"mock":{"agents":[{"name":"session-worker","type":"agent"}],"inbox":[{"id":"in_session_1","recipient":"session-worker","from":"lead","text":"Session delivery"}],"delivery":{"target":"session-worker","targetKind":"session","serverDeliveryState":true,"result":{"status":"delivered","deliveryId":"del_session_1"}}}} diff --git a/evals/suites/delivery-modes/cases.md b/evals/suites/delivery-modes/cases.md new file mode 100644 index 000000000..1c04f6bda --- /dev/null +++ b/evals/suites/delivery-modes/cases.md @@ -0,0 +1,310 @@ +# Delivery Modes Cases +Delivery mode cases verify that DeliveryRunner semantics preserve wait versus steer intent, ordered delivery, terminal acknowledgements, and retryable failure behavior. + +## delivery-modes.wait-acks-delivered +Executor: relay +Kind: regression +Tags: delivery, wait, ack +Human Review: false + +### Message +A queued inbox item is delivered in wait mode and the runner should ack it as delivered. + +### Mock +```json +{ + "agents": [{ "name": "worker", "type": "agent" }], + "inbox": [ + { "id": "in_wait_1", "recipient": "worker", "from": "lead", "text": "Please review the patch" } + ], + "delivery": { + "target": "worker", + "serverDeliveryState": true, + "result": { "status": "delivered", "metadata": { "injected": true } } + } +} +``` + +### Operations +```json +[ + { "op": "deliver", "as": "worker", "mode": "wait", "reason": "message" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- delivered +toolCallsInclude: +- deliver +minToolCalls: 1 +must: +- Ack inbox item `in_wait_1` with delivered state. +- Preserve delivery result metadata on the ack. +mustNot: +- Mark the item failed or deferred. + +## delivery-modes.steer-interrupts-immediately +Executor: relay +Kind: capability +Tags: delivery, steer, interrupt +Human Review: false + +### Message +A steer-mode delivery should use an immediate/interrupt injection path for the active agent. + +### Mock +```json +{ + "agents": [{ "name": "navigator", "type": "agent" }], + "inbox": [ + { "id": "in_steer_1", "recipient": "navigator", "from": "lead", "text": "Stop and inspect the failing test now" } + ], + "delivery": { + "target": "navigator", + "serverDeliveryState": true, + "result": { "status": "accepted", "metadata": { "mode": "interrupt" } } + } +} +``` + +### Operations +```json +[ + { "op": "deliver", "as": "navigator", "mode": "steer", "reason": "mention", "priority": "urgent" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- accepted +- interrupt +toolCallsInclude: +- deliver +must: +- Pass steer intent to the delivery adapter as an interrupt-style context. +- Ack the accepted item without scheduling a retry. +mustNot: +- Treat steer mode as an idle wait. + +## delivery-modes.deferred-result-schedules-availability +Executor: relay +Kind: regression +Tags: delivery, defer, backoff +Human Review: false + +### Message +When the adapter defers a message, the runner should defer the inbox item until the supplied availability time. + +### Mock +```json +{ + "agents": [{ "name": "worker", "type": "agent" }], + "inbox": [ + { "id": "in_defer_1", "recipient": "worker", "from": "lead", "text": "Handle this after the current task" } + ], + "delivery": { + "target": "worker", + "serverDeliveryState": true, + "result": { + "status": "deferred", + "availableAt": "2026-05-27T11:00:00.000Z", + "reason": "busy", + "metadata": { "queue": "runtime" } + } + } +} +``` + +### Operations +```json +[ + { "op": "deliver", "as": "worker", "mode": "wait", "reason": "message" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- deferred +- 2026-05-27T11:00:00.000Z +- busy +must: +- Defer inbox item `in_defer_1` with the adapter-provided availability timestamp. +- Preserve defer metadata. +mustNot: +- Ack a deferred item as delivered. +- Mark a deferred item retryable failure. + +## delivery-modes.failed-result-terminal +Executor: relay +Kind: regression +Tags: delivery, failure, terminal +Human Review: false + +### Message +An adapter-reported failed result should become a terminal non-retryable inbox failure. + +### Mock +```json +{ + "agents": [{ "name": "worker", "type": "agent" }], + "inbox": [ + { "id": "in_fail_1", "recipient": "worker", "from": "lead", "text": "Deliver to unavailable runtime" } + ], + "delivery": { + "target": "worker", + "serverDeliveryState": true, + "result": { + "status": "failed", + "reason": "runtime rejected message", + "metadata": { "terminal": true } + } + } +} +``` + +### Operations +```json +[ + { "op": "deliver", "as": "worker", "mode": "wait", "reason": "message" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- failed +- runtime rejected message +must: +- Fail inbox item `in_fail_1` with retry set to false. +- Include terminal failure metadata. +mustNot: +- Retry adapter-reported failed results. + +## delivery-modes.thrown-error-retryable +Executor: relay +Kind: regression +Tags: delivery, retry, error +Human Review: false + +### Message +If injection throws, DeliveryRunner should record a retryable failure instead of losing the inbox item. + +### Mock +```json +{ + "agents": [{ "name": "worker", "type": "agent" }], + "inbox": [ + { "id": "in_retry_1", "recipient": "worker", "from": "lead", "text": "Adapter will throw" } + ], + "delivery": { + "target": "worker", + "serverDeliveryState": true, + "throws": "adapter unavailable" + } +} +``` + +### Operations +```json +[ + { "op": "deliver", "as": "worker", "mode": "wait", "reason": "message" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- adapter unavailable +must: +- Record a retryable failure for `in_retry_1`. +- Invoke the delivery error hook before failing the item. +mustNot: +- Ack an item whose adapter injection threw. + +## delivery-modes.orders-multiple-inbox-items +Executor: relay +Kind: capability +Tags: delivery, ordering +Human Review: false + +### Message +Multiple queued inbox items should be delivered in subscription order. + +### Mock +```json +{ + "agents": [{ "name": "worker", "type": "agent" }], + "inbox": [ + { "id": "in_order_1", "recipient": "worker", "from": "lead", "text": "First item" }, + { "id": "in_order_2", "recipient": "worker", "from": "lead", "text": "Second item" } + ], + "delivery": { + "target": "worker", + "serverDeliveryState": true, + "result": { "status": "delivered" } + } +} +``` + +### Operations +```json +[ + { "op": "deliver", "as": "worker", "mode": "wait", "reason": "message" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- delivered +must: +- Inject `in_order_1` before `in_order_2`. +- Ack both items after successful delivery. +mustNot: +- Reorder queued inbox items. + +## delivery-modes.session-receive-message-contract +Executor: relay +Kind: regression +Tags: delivery, session +Human Review: false + +### Message +A delivery target with receiveMessage should receive message-mode context rather than adapter injection context. + +### Mock +```json +{ + "agents": [{ "name": "session-worker", "type": "agent" }], + "inbox": [ + { "id": "in_session_1", "recipient": "session-worker", "from": "lead", "text": "Session delivery" } + ], + "delivery": { + "target": "session-worker", + "targetKind": "session", + "serverDeliveryState": true, + "result": { "status": "delivered", "deliveryId": "del_session_1" } + } +} +``` + +### Operations +```json +[ + { "op": "deliver", "as": "session-worker", "mode": "wait", "reason": "mention", "idempotencyKey": "idem-session-1" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- del_session_1 +must: +- Call receiveMessage with a message context and deterministic delivery id. +- Ack the inbox item after a delivered receipt. +mustNot: +- Require an inject method when receiveMessage is present. diff --git a/evals/suites/delivery-modes/rubric.md b/evals/suites/delivery-modes/rubric.md new file mode 100644 index 000000000..745424354 --- /dev/null +++ b/evals/suites/delivery-modes/rubric.md @@ -0,0 +1,3 @@ +# Delivery Modes Rubric + +Delivery mode cases pass when the in-memory executor demonstrates DeliveryRunner behavior against durable inbox state. Successful runs must show wait and steer mode context mapping, ordered processing, delivered/accepted acknowledgements, adapter deferred scheduling, terminal failures for explicit failed results, retryable failures for thrown errors, and RelayCapabilityError when delivery state is unsupported. Passing output should include the relevant status strings, record the `deliver` tool call, and avoid contradictory terminal states such as ack plus fail for the same item. diff --git a/evals/suites/facade/cases.jsonl b/evals/suites/facade/cases.jsonl new file mode 100644 index 000000000..7ec5573a7 --- /dev/null +++ b/evals/suites/facade/cases.jsonl @@ -0,0 +1,10 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"facade.register-single-agent-client","suite":"facade","executor":"relay","kind":"capability","input":{"message":"Register a single agent through the workspace facade and return a live client.","operation":[{"op":"register_agent","name":"triager","type":"agent","persona":"routes work"}]},"expected":{"ok":true,"agentPresence":[{"name":"triager","status":"online"}],"contentIncludes":["triager","at_eval_triager","status"],"toolCallsInclude":["register_agent"],"must":["Return an agent client with identity, token, and listener predicate builders."],"mustNot":["Require a second lookup before the registered agent appears online."],"humanReviewRequired":false},"tags":["facade","agents"],"mock":{"agents":[]}} +{"id":"facade.register-agents-batch","suite":"facade","executor":"relay","kind":"capability","input":{"message":"Register multiple agents in one facade call.","operation":[{"op":"register_agents","agents":[{"name":"planner"},"engineer"]},{"op":"list_agents"}]},"expected":{"ok":true,"agentPresence":[{"name":"planner","status":"online"},{"name":"engineer","status":"online"}],"contentIncludes":["planner","engineer"],"toolCallsInclude":["register_agents","list_agents"],"must":["Accept mixed object and string agent references.","Return a client for each registered agent."],"mustNot":["Drop later agents from the batch."],"humanReviewRequired":false},"tags":["facade","agents"],"mock":{"agents":[]}} +{"id":"facade.register-agents-duplicate-fails-fast","suite":"facade","executor":"relay","kind":"regression","input":{"message":"Attempt to register a batch with duplicate agent names.","operation":[{"op":"register_agents","agents":[{"name":"planner"},{"name":"planner"}]},{"op":"list_agents"}]},"expected":{"ok":false,"contentIncludes":["error register_agents"],"must":["Detect in-batch duplicate names before calling the backing registration API for later entries."],"mustNot":["Leave a partially registered duplicate agent in the directory."],"humanReviewRequired":false},"tags":["facade","agents","errors"],"mock":{"agents":[]}} +{"id":"facade.workspace-info","suite":"facade","executor":"relay","kind":"capability","input":{"message":"Read workspace information from the facade.","operation":[{"op":"workspace_info"}]},"expected":{"ok":true,"contentIncludes":["ws_eval","Relay Eval Workspace"],"toolCallsInclude":["workspace_info"],"must":["Return the current workspace identity through the facade."],"mustNot":["Expose unrelated agent token state as workspace identity."],"humanReviewRequired":false},"tags":["facade","workspace"],"mock":{"workspace":{"id":"ws_eval","name":"Relay Eval Workspace","key":"rk_eval"}}} +{"id":"facade.reconnect-agent-token","suite":"facade","executor":"relay","kind":"regression","input":{"message":"Reconnect an existing agent client from its persisted API token.","operation":[{"op":"reconnect","apiToken":"tok-self"}]},"expected":{"ok":true,"contentIncludes":["self","tok-self"],"toolCallsInclude":["reconnect"],"must":["Resolve identity from the token-scoped agent client.","Preserve the token on the reconnected live client."],"mustNot":["Treat reconnect as a new registration with a new token."],"humanReviewRequired":false},"tags":["facade","reconnect"],"mock":{"agents":[{"name":"self","type":"agent","id":"id-self","token":"tok-self","status":"online"}]}} +{"id":"facade.notify-agent-steer","suite":"facade","executor":"relay","kind":"capability","input":{"message":"Notify an agent target with immediate delivery semantics.","operation":[{"op":"notify","target":"@reviewer","options":{"text":"incident update","delivery":"immediate","subject":"triager"}}]},"expected":{"ok":true,"messageExists":[{"kind":"dm","text":"incident update"}],"contentIncludes":["incident update"],"toolCallsInclude":["notify"],"must":["Route agent notify targets to direct messaging.","Deliver the notification as a direct message to the target agent."],"mustNot":["Send the notification to a channel."],"humanReviewRequired":false},"tags":["facade","notify","delivery"],"mock":{"agents":[{"name":"triager","type":"agent"},{"name":"reviewer","type":"agent"}]}} +{"id":"facade.notify-agent-wait","suite":"facade","executor":"relay","kind":"capability","input":{"message":"Notify an agent target with queued delivery semantics.","operation":[{"op":"notify","target":"@reviewer","options":{"text":"please review","delivery":"on-idle","subject":"planner"}}]},"expected":{"ok":true,"messageExists":[{"kind":"dm","text":"please review"}],"contentIncludes":["reviewer"],"toolCallsInclude":["notify"],"must":["Route agent notify targets to direct messaging.","Preserve the target agent in the direct-message envelope."],"mustNot":["Force the subject handle into caller-provided text."],"humanReviewRequired":false},"tags":["facade","notify","delivery"],"mock":{"agents":[{"name":"reviewer","type":"agent"},{"name":"planner","type":"agent"}]}} +{"id":"facade.notify-default-text-includes-subject","suite":"facade","executor":"relay","kind":"regression","input":{"message":"Notify an agent without explicit text so the facade builds the default label and subject body.","operation":[{"op":"notify","target":"@reviewer","options":{"type":"handoff","subject":"planner","delivery":"on-idle"}}]},"expected":{"ok":true,"messageExists":[{"kind":"dm","text":"notification"}],"contentIncludes":["notification","reviewer"],"toolCallsInclude":["notify"],"must":["Build a default notification body when `text` is omitted."],"mustNot":["Drop the direct-message target when generated default text is used."],"humanReviewRequired":false},"tags":["facade","notify"],"mock":{"agents":[{"name":"reviewer","type":"agent"},{"name":"planner","type":"agent"}]}} diff --git a/evals/suites/facade/cases.md b/evals/suites/facade/cases.md new file mode 100644 index 000000000..0c264bb8a --- /dev/null +++ b/evals/suites/facade/cases.md @@ -0,0 +1,330 @@ +# Facade Cases + +These cases pin the high-level workspace facade surfaces for registering agents, +reconnecting agent clients, notifying targets, and reading workspace +information through the in-memory Relay SDK executor. + +## facade.register-single-agent-client +Executor: relay +Kind: capability +Tags: facade, agents +Human Review: false + +### Message +Register a single agent through the workspace facade and return a live client. + +### Mock +```json +{ + "agents": [] +} +``` + +### Operations +```json +[ + { "op": "register_agent", "name": "triager", "type": "agent", "persona": "routes work" } +] +``` + +### Deterministic Checks +ok: true +agentPresence: +- {"name":"triager","status":"online"} +contentIncludes: +- triager +- at_eval_triager +- status +toolCallsInclude: +- register_agent + +### Must +- Return an agent client with identity, token, and listener predicate builders. + +### Must Not +- Require a second lookup before the registered agent appears online. + +## facade.register-agents-batch +Executor: relay +Kind: capability +Tags: facade, agents +Human Review: false + +### Message +Register multiple agents in one facade call. + +### Mock +```json +{ + "agents": [] +} +``` + +### Operations +```json +[ + { "op": "register_agents", "agents": [{ "name": "planner" }, "engineer"] }, + { "op": "list_agents" } +] +``` + +### Deterministic Checks +ok: true +agentPresence: +- {"name":"planner","status":"online"} +- {"name":"engineer","status":"online"} +contentIncludes: +- planner +- engineer +toolCallsInclude: +- register_agents +- list_agents + +### Must +- Accept mixed object and string agent references. +- Return a client for each registered agent. + +### Must Not +- Drop later agents from the batch. + +## facade.register-agents-duplicate-fails-fast +Executor: relay +Kind: regression +Tags: facade, agents, errors +Human Review: false + +### Message +Attempt to register a batch with duplicate agent names. + +### Mock +```json +{ + "agents": [] +} +``` + +### Operations +```json +[ + { "op": "register_agents", "agents": [{ "name": "planner" }, { "name": "planner" }] }, + { "op": "list_agents" } +] +``` + +### Deterministic Checks +ok: false +contentIncludes: +- error register_agents +must: +- Fail before partially registering the duplicated batch. +mustNot: +- Register planner twice. + +### Must +- Detect in-batch duplicate names before calling the backing registration API for later entries. + +### Must Not +- Leave a partially registered duplicate agent in the directory. + +## facade.workspace-info +Executor: relay +Kind: capability +Tags: facade, workspace +Human Review: false + +### Message +Read workspace information from the facade. + +### Mock +```json +{ + "workspace": { "id": "ws_eval", "name": "Relay Eval Workspace", "key": "rk_eval" } +} +``` + +### Operations +```json +[ + { "op": "workspace_info" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- ws_eval +- Relay Eval Workspace +toolCallsInclude: +- workspace_info + +### Must +- Return the current workspace identity through the facade. + +### Must Not +- Expose unrelated agent token state as workspace identity. + +## facade.reconnect-agent-token +Executor: relay +Kind: regression +Tags: facade, reconnect +Human Review: false + +### Message +Reconnect an existing agent client from its persisted API token. + +### Mock +```json +{ + "agents": [ + { "name": "self", "type": "agent", "id": "id-self", "token": "tok-self", "status": "online" } + ] +} +``` + +### Operations +```json +[ + { "op": "reconnect", "apiToken": "tok-self" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- self +- tok-self +toolCallsInclude: +- reconnect + +### Must +- Resolve identity from the token-scoped agent client. +- Preserve the token on the reconnected live client. + +### Must Not +- Treat reconnect as a new registration with a new token. + +## facade.notify-agent-steer +Executor: relay +Kind: capability +Tags: facade, notify, delivery +Human Review: false + +### Message +Notify an agent target with immediate delivery semantics. + +### Mock +```json +{ + "agents": [ + { "name": "triager", "type": "agent" }, + { "name": "reviewer", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { "op": "notify", "target": "@reviewer", "options": { "text": "incident update", "delivery": "immediate", "subject": "triager" } } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"kind":"dm","text":"incident update"} +contentIncludes: +- incident update +toolCallsInclude: +- notify + +### Must +- Route agent notify targets to direct messaging. +- Deliver the notification as a direct message to the target agent. + +### Must Not +- Send the notification to a channel. + +## facade.notify-agent-wait +Executor: relay +Kind: capability +Tags: facade, notify, delivery +Human Review: false + +### Message +Notify an agent target with queued delivery semantics. + +### Mock +```json +{ + "agents": [ + { "name": "reviewer", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { "op": "notify", "target": "@reviewer", "options": { "text": "please review", "delivery": "on-idle", "subject": "planner" } } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"kind":"dm","text":"please review"} +contentIncludes: +- reviewer +toolCallsInclude: +- notify + +### Must +- Route agent notify targets to direct messaging. +- Preserve the target agent in the direct-message envelope. + +### Must Not +- Force the subject handle into caller-provided text. + +## facade.notify-default-text-includes-subject +Executor: relay +Kind: regression +Tags: facade, notify +Human Review: false + +### Message +Notify an agent without explicit text so the facade builds the default label and subject body. + +### Mock +```json +{ + "agents": [ + { "name": "reviewer", "type": "agent" }, + { "name": "planner", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { "op": "notify", "target": "@reviewer", "options": { "type": "handoff", "subject": "planner", "delivery": "on-idle" } } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"kind":"dm","text":"notification"} +contentIncludes: +- notification +- reviewer +toolCallsInclude: +- notify + +### Must +- Build a default notification body when `text` is omitted. + +### Must Not +- Drop the direct-message target when generated default text is used. diff --git a/evals/suites/facade/rubric.md b/evals/suites/facade/rubric.md new file mode 100644 index 000000000..9a94fc0c2 --- /dev/null +++ b/evals/suites/facade/rubric.md @@ -0,0 +1,7 @@ +# Facade Rubric + +Facade cases are deterministic. A passing run must show that workspace +registration returns usable live agent clients, batch registration is complete +and duplicate-safe, reconnect preserves token-bound identity, workspace info is +read from the facade, and notify routes agent targets through direct messages +with caller-provided or generated text. diff --git a/evals/suites/listeners/cases.jsonl b/evals/suites/listeners/cases.jsonl new file mode 100644 index 000000000..360733dc3 --- /dev/null +++ b/evals/suites/listeners/cases.jsonl @@ -0,0 +1,12 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"listeners.message-created-selector","suite":"listeners","executor":"relay","kind":"capability","input":{"message":"Subscribe to public message creation events and emit a matching raw message event.","operation":[{"op":"add_listener","selector":"message.created"},{"op":"emit_event","raw":{"type":"messageCreated","channel":"ops","message":{"id":"m-listen-1","messageId":"m-listen-1","text":"hello ops","from":{"name":"alice"},"channel":{"name":"ops"}}}}]},"expected":{"ok":true,"eventEmitted":["messageCreated"],"contentIncludes":["listener message.created"],"toolCallsInclude":["add_listener","emit_event"],"must":["Convert raw `messageCreated` events to public `message.created` events.","Preserve the message envelope channel."],"mustNot":["Deliver unrelated raw event types to the exact selector."],"humanReviewRequired":false},"tags":["listeners","messages"],"mock":{"agents":[{"name":"alice","type":"agent"}],"channels":[{"name":"ops","members":["alice"]}]}} +{"id":"listeners.message-predicate-channel-mention","suite":"listeners","executor":"relay","kind":"regression","input":{"message":"Use the message-created predicate with channel and mention filters.","operation":[{"op":"on_predicate","predicate":"message.created","channel":"#ops","mentions":"eng"},{"op":"emit_event","raw":{"type":"messageCreated","channel":"random","message":{"id":"wrong-channel","text":"@eng hi"}}},{"op":"emit_event","raw":{"type":"messageCreated","channel":"ops","message":{"id":"no-mention","text":"hi"}}},{"op":"emit_event","raw":{"type":"messageCreated","channel":"#ops","message":{"id":"match","text":"hey @eng","mentions":["eng"]}}}]},"expected":{"ok":true,"eventEmitted":["messageCreated"],"contentIncludes":["predicate messageCreated"],"must":["Strip `#` from channel filters before matching.","Match mentions from either explicit mention arrays or message text."],"mustNot":["Fire for the right mention in the wrong channel."],"humanReviewRequired":false},"tags":["listeners","messages","predicates"],"mock":{"agents":[{"name":"eng","type":"agent"}],"channels":[{"name":"ops","members":["eng"]}]}} +{"id":"listeners.message-read-public-event","suite":"listeners","executor":"relay","kind":"capability","input":{"message":"Map a raw message read event to its public event.","operation":[{"op":"add_listener","selector":"message.read"},{"op":"emit_event","raw":{"type":"messageRead","messageId":"m-read-1","agentName":"bob","readAt":"2026-06-09T09:00:00.000Z"}}]},"expected":{"ok":true,"eventEmitted":["messageRead"],"contentIncludes":["listener message.read"],"must":["Preserve the message id, reader name, and read timestamp."],"mustNot":["Represent read receipts as message creation events."],"humanReviewRequired":false},"tags":["listeners","read-receipts"],"mock":{}} +{"id":"listeners.reaction-added-removed-public-event","suite":"listeners","executor":"relay","kind":"regression","input":{"message":"Map added and removed reaction events to public reaction actions.","operation":[{"op":"add_listener","selector":"message.reacted"},{"op":"emit_event","raw":{"type":"reactionAdded","messageId":"m-react-1","emoji":"eyes","agentName":"bob"}},{"op":"emit_event","raw":{"type":"reactionRemoved","messageId":"m-react-1","emoji":"eyes","agentName":"bob"}}]},"expected":{"ok":true,"eventEmitted":[{"type":"reactionAdded","messageId":"m-react-1"},{"type":"reactionRemoved","messageId":"m-react-1"}],"contentIncludes":["listener message.reacted"],"minToolCalls":3,"must":["Surface both reaction add and remove events through the same public event type.","Preserve the action discriminator."],"mustNot":["Collapse add and remove into one indistinguishable event."],"humanReviewRequired":false},"tags":["listeners","reactions"],"mock":{}} +{"id":"listeners.action-predicate-completed-by-caller","suite":"listeners","executor":"relay","kind":"capability","input":{"message":"Subscribe to an action predicate for a completed action invoked by a selected caller.","operation":[{"op":"on_predicate","predicate":"action","action":"spawn-claude","phase":"completed","calledBy":"planner"},{"op":"register_action","name":"spawn-claude","handlerFixture":"echo_text"},{"op":"invoke_action","name":"spawn-claude","as":"other","input":{"text":"ignored"}},{"op":"invoke_action","name":"spawn-claude","as":"planner","input":{"text":"matched"}}]},"expected":{"ok":true,"eventEmitted":["action.completed"],"contentIncludes":["spawn-claude","predicate action.completed"],"mustNot":["Fire for a matching action completed by a different caller."],"must":["Filter action events by action name, phase, and caller."],"humanReviewRequired":false},"tags":["listeners","actions","predicates"],"mock":{"agents":[{"name":"planner","type":"agent"}]}} +{"id":"listeners.status-predicate-matches-changed-and-specific","suite":"listeners","executor":"relay","kind":"regression","input":{"message":"Subscribe to an agent status predicate and emit both status.changed and status.idle events.","operation":[{"op":"on_predicate","predicate":"status","agentId":"a-eng","status":"idle"},{"op":"emit_session_event","agentId":"a-other","event":{"type":"status.changed","status":"idle"}},{"op":"emit_session_event","agentId":"a-eng","event":{"type":"status.changed","status":"active"}},{"op":"emit_session_event","agentId":"a-eng","event":{"type":"status.changed","status":"idle","reason":"waiting"}},{"op":"emit_session_event","agentId":"a-eng","event":{"type":"status.idle","reason":"no work"}}]},"expected":{"ok":true,"eventEmitted":["status.changed","status.idle"],"contentIncludes":["predicate status.changed","predicate status.idle"],"mustNot":["Fire for events from a different agent id."],"must":["Fire for both `status.changed` with a matching status and the specific status event."],"humanReviewRequired":false},"tags":["listeners","session","status"],"mock":{"agents":[{"name":"engineer","type":"agent","id":"a-eng"}]}} +{"id":"listeners.tool-called-predicate-with-filter","suite":"listeners","executor":"relay","kind":"capability","input":{"message":"Subscribe to a tool-called predicate with an input filter.","operation":[{"op":"add_listener","selector":"tool.called"},{"op":"emit_session_event","agentId":"a-eng","event":{"type":"tool.called","tool":"bash","input":{"command":"ls"}}},{"op":"emit_session_event","agentId":"a-eng","event":{"type":"tool.called","tool":"bash","input":{"command":"npm test"}}}]},"expected":{"ok":true,"eventEmitted":["tool.called"],"contentIncludes":["listener tool.called"],"mustNot":["Fire for the same tool when the input filter does not match."],"must":["Filter tool call predicates by tool name and input content."],"humanReviewRequired":false},"tags":["listeners","tools","predicates"],"mock":{"agents":[{"name":"engineer","type":"agent","id":"a-eng"}]}} +{"id":"listeners.selector-wildcards","suite":"listeners","executor":"relay","kind":"regression","input":{"message":"Match exact, prefix wildcard, and catch-all selectors against public event types.","operation":[{"op":"match_selector","selector":"message.*","type":"message.read"},{"op":"match_selector","selector":"*","type":"agent.status.idle"},{"op":"match_selector","selector":"message.created","type":"message.read"}]},"expected":{"ok":true,"contentIncludes":[true,false],"toolCallsInclude":["match_selector"],"minToolCalls":3,"must":["Treat `*` as a catch-all selector.","Treat `.*` as a starts-with selector.","Require exact equality for non-wildcard selectors."],"mustNot":["Match `message.created` against `message.read`."],"humanReviewRequired":false},"tags":["listeners","selectors"],"mock":{}} +{"id":"listeners.to-public-thread-reply-envelope","suite":"listeners","executor":"relay","kind":"capability","input":{"message":"Convert a raw thread reply event into a public event with parent envelope metadata.","operation":[{"op":"to_public_event","raw":{"type":"threadReply","channel":"ops","message":{"id":"reply-1","messageId":"reply-1","parentId":"parent-1","text":"on it","from":{"name":"reviewer"}}}}]},"expected":{"ok":true,"contentIncludes":["thread.reply","parent-1","reviewer","ops"],"toolCallsInclude":["to_public_event"],"must":["Map `threadReply` to `thread.reply`.","Populate the envelope parent from `message.parentId`."],"mustNot":["Drop channel information when the raw event carries channel context."],"humanReviewRequired":false},"tags":["listeners","public-events","threads"],"mock":{}} +{"id":"listeners.unsurfaced-event-ignored","suite":"listeners","executor":"relay","kind":"regression","input":{"message":"Try to convert a raw event type that is not part of the public listener surface.","operation":[{"op":"to_public_event","raw":{"type":"agentOnline","agent":{"name":"worker"}}}]},"expected":{"ok":true,"contentIncludes":["undefined"],"must":["Keep unsupported messaging events out of the public listener stream."],"mustNot":["Fabricate a public event name for unknown raw events."],"humanReviewRequired":false},"tags":["listeners","public-events"],"mock":{}} diff --git a/evals/suites/listeners/cases.md b/evals/suites/listeners/cases.md new file mode 100644 index 000000000..6d55f722e --- /dev/null +++ b/evals/suites/listeners/cases.md @@ -0,0 +1,433 @@ +# Listeners Cases + +These cases pin the listener hub, predicate DSL, selector matching, public event +mapping, and session-event emission surfaces used by agents subscribing to Relay +SDK events. + +## listeners.message-created-selector +Executor: relay +Kind: capability +Tags: listeners, messages +Human Review: false + +### Message +Subscribe to public message creation events and emit a matching raw message event. + +### Mock +```json +{ + "agents": [{ "name": "alice", "type": "agent" }], + "channels": [{ "name": "ops", "members": ["alice"] }] +} +``` + +### Operations +```json +[ + { "op": "add_listener", "selector": "message.created" }, + { + "op": "emit_event", + "raw": { + "type": "messageCreated", + "channel": "ops", + "message": { + "id": "m-listen-1", + "messageId": "m-listen-1", + "text": "hello ops", + "from": { "name": "alice" }, + "channel": { "name": "ops" } + } + } + } +] +``` + +### Deterministic Checks +ok: true +eventEmitted: +- messageCreated +contentIncludes: +- listener message.created +toolCallsInclude: +- add_listener +- emit_event + +### Must +- Convert raw `messageCreated` events to public `message.created` events. +- Preserve the message envelope channel. + +### Must Not +- Deliver unrelated raw event types to the exact selector. + +## listeners.message-predicate-channel-mention +Executor: relay +Kind: regression +Tags: listeners, messages, predicates +Human Review: false + +### Message +Use the message-created predicate with channel and mention filters. + +### Mock +```json +{ + "agents": [{ "name": "eng", "type": "agent" }], + "channels": [{ "name": "ops", "members": ["eng"] }] +} +``` + +### Operations +```json +[ + { "op": "on_predicate", "predicate": "message.created", "channel": "#ops", "mentions": "eng" }, + { "op": "emit_event", "raw": { "type": "messageCreated", "channel": "random", "message": { "id": "wrong-channel", "text": "@eng hi" } } }, + { "op": "emit_event", "raw": { "type": "messageCreated", "channel": "ops", "message": { "id": "no-mention", "text": "hi" } } }, + { "op": "emit_event", "raw": { "type": "messageCreated", "channel": "#ops", "message": { "id": "match", "text": "hey @eng", "mentions": ["eng"] } } } +] +``` + +### Deterministic Checks +ok: true +eventEmitted: +- messageCreated +contentIncludes: +- predicate messageCreated +must: +- Fire only for the event in the selected channel that mentions the target agent. +mustNot: +- Include wrong-channel +- Include no-mention + +### Must +- Strip `#` from channel filters before matching. +- Match mentions from either explicit mention arrays or message text. + +### Must Not +- Fire for the right mention in the wrong channel. + +## listeners.message-read-public-event +Executor: relay +Kind: capability +Tags: listeners, read-receipts +Human Review: false + +### Message +Map a raw message read event to its public event. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "add_listener", "selector": "message.read" }, + { "op": "emit_event", "raw": { "type": "messageRead", "messageId": "m-read-1", "agentName": "bob", "readAt": "2026-06-09T09:00:00.000Z" } } +] +``` + +### Deterministic Checks +ok: true +eventEmitted: +- messageRead +contentIncludes: +- listener message.read + +### Must +- Preserve the message id, reader name, and read timestamp. + +### Must Not +- Represent read receipts as message creation events. + +## listeners.reaction-added-removed-public-event +Executor: relay +Kind: regression +Tags: listeners, reactions +Human Review: false + +### Message +Map added and removed reaction events to public reaction actions. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "add_listener", "selector": "message.reacted" }, + { "op": "emit_event", "raw": { "type": "reactionAdded", "messageId": "m-react-1", "emoji": "eyes", "agentName": "bob" } }, + { "op": "emit_event", "raw": { "type": "reactionRemoved", "messageId": "m-react-1", "emoji": "eyes", "agentName": "bob" } } +] +``` + +### Deterministic Checks +ok: true +eventEmitted: +- {"type":"reactionAdded","messageId":"m-react-1"} +- {"type":"reactionRemoved","messageId":"m-react-1"} +contentIncludes: +- listener message.reacted +minToolCalls: 3 + +### Must +- Surface both reaction add and remove events through the same public event type. +- Preserve the action discriminator. + +### Must Not +- Collapse add and remove into one indistinguishable event. + +## listeners.action-predicate-completed-by-caller +Executor: relay +Kind: capability +Tags: listeners, actions, predicates +Human Review: false + +### Message +Subscribe to an action predicate for a completed action invoked by a selected caller. + +### Mock +```json +{ + "agents": [{ "name": "planner", "type": "agent" }] +} +``` + +### Operations +```json +[ + { "op": "on_predicate", "predicate": "action", "action": "spawn-claude", "phase": "completed", "calledBy": "planner" }, + { "op": "register_action", "name": "spawn-claude", "handlerFixture": "echo_text" }, + { "op": "invoke_action", "name": "spawn-claude", "as": "other", "input": { "text": "ignored" } }, + { "op": "invoke_action", "name": "spawn-claude", "as": "planner", "input": { "text": "matched" } } +] +``` + +### Deterministic Checks +ok: true +eventEmitted: +- action.completed +contentIncludes: +- spawn-claude +- predicate action.completed +mustNot: +- Include "\"name\":\"other\"" + +### Must +- Filter action events by action name, phase, and caller. + +### Must Not +- Fire for a matching action completed by a different caller. + +## listeners.status-predicate-matches-changed-and-specific +Executor: relay +Kind: regression +Tags: listeners, session, status +Human Review: false + +### Message +Subscribe to an agent status predicate and emit both status.changed and status.idle events. + +### Mock +```json +{ + "agents": [{ "name": "engineer", "type": "agent", "id": "a-eng" }] +} +``` + +### Operations +```json +[ + { "op": "on_predicate", "predicate": "status", "agentId": "a-eng", "status": "idle" }, + { "op": "emit_session_event", "agentId": "a-other", "event": { "type": "status.changed", "status": "idle" } }, + { "op": "emit_session_event", "agentId": "a-eng", "event": { "type": "status.changed", "status": "active" } }, + { "op": "emit_session_event", "agentId": "a-eng", "event": { "type": "status.changed", "status": "idle", "reason": "waiting" } }, + { "op": "emit_session_event", "agentId": "a-eng", "event": { "type": "status.idle", "reason": "no work" } } +] +``` + +### Deterministic Checks +ok: true +eventEmitted: +- status.changed +- status.idle +contentIncludes: +- predicate status.changed +- predicate status.idle +mustNot: +- Include a-other + +### Must +- Fire for both `status.changed` with a matching status and the specific status event. + +### Must Not +- Fire for events from a different agent id. + +## listeners.tool-called-predicate-with-filter +Executor: relay +Kind: capability +Tags: listeners, tools, predicates +Human Review: false + +### Message +Subscribe to a tool-called predicate with an input filter. + +### Mock +```json +{ + "agents": [{ "name": "engineer", "type": "agent", "id": "a-eng" }] +} +``` + +### Operations +```json +[ + { "op": "add_listener", "selector": "tool.called" }, + { "op": "emit_session_event", "agentId": "a-eng", "event": { "type": "tool.called", "tool": "bash", "input": { "command": "ls" } } }, + { "op": "emit_session_event", "agentId": "a-eng", "event": { "type": "tool.called", "tool": "bash", "input": { "command": "npm test" } } } +] +``` + +### Deterministic Checks +ok: true +eventEmitted: +- tool.called +contentIncludes: +- listener tool.called +mustNot: +- Include "\"command\":\"ls\"" + +### Must +- Filter tool call predicates by tool name and input content. + +### Must Not +- Fire for the same tool when the input filter does not match. + +## listeners.selector-wildcards +Executor: relay +Kind: regression +Tags: listeners, selectors +Human Review: false + +### Message +Match exact, prefix wildcard, and catch-all selectors against public event types. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "match_selector", "selector": "message.*", "type": "message.read" }, + { "op": "match_selector", "selector": "*", "type": "agent.status.idle" }, + { "op": "match_selector", "selector": "message.created", "type": "message.read" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- true +- false +toolCallsInclude: +- match_selector +minToolCalls: 3 + +### Must +- Treat `*` as a catch-all selector. +- Treat `.*` as a starts-with selector. +- Require exact equality for non-wildcard selectors. + +### Must Not +- Match `message.created` against `message.read`. + +## listeners.to-public-thread-reply-envelope +Executor: relay +Kind: capability +Tags: listeners, public-events, threads +Human Review: false + +### Message +Convert a raw thread reply event into a public event with parent envelope metadata. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { + "op": "to_public_event", + "raw": { + "type": "threadReply", + "channel": "ops", + "message": { + "id": "reply-1", + "messageId": "reply-1", + "parentId": "parent-1", + "text": "on it", + "from": { "name": "reviewer" } + } + } + } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- thread.reply +- parent-1 +- reviewer +- ops +toolCallsInclude: +- to_public_event + +### Must +- Map `threadReply` to `thread.reply`. +- Populate the envelope parent from `message.parentId`. + +### Must Not +- Drop channel information when the raw event carries channel context. + +## listeners.unsurfaced-event-ignored +Executor: relay +Kind: regression +Tags: listeners, public-events +Human Review: false + +### Message +Try to convert a raw event type that is not part of the public listener surface. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "to_public_event", "raw": { "type": "agentOnline", "agent": { "name": "worker" } } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- undefined +must: +- Return no public event for unsupported raw event types. +mustNot: +- Emit agentOnline as a public event. + +### Must +- Keep unsupported messaging events out of the public listener stream. + +### Must Not +- Fabricate a public event name for unknown raw events. diff --git a/evals/suites/listeners/rubric.md b/evals/suites/listeners/rubric.md new file mode 100644 index 000000000..ce19ff568 --- /dev/null +++ b/evals/suites/listeners/rubric.md @@ -0,0 +1,7 @@ +# Listeners Rubric + +Listener cases are deterministic. A passing run must show that public listener +selectors and predicate subscriptions fire only for matching message, read, +reaction, action, status, and tool events; public event mapping preserves +message envelope details; wildcard selector matching is exact; and unsupported +raw event types are ignored rather than surfaced. diff --git a/evals/suites/messaging/cases.jsonl b/evals/suites/messaging/cases.jsonl new file mode 100644 index 000000000..16fdef087 --- /dev/null +++ b/evals/suites/messaging/cases.jsonl @@ -0,0 +1,7 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"messaging.channel-post-visible","suite":"messaging","executor":"relay","kind":"capability","input":{"message":"Post a channel update and list the channel messages.","operation":[{"op":"post_message","as":"Ada","channel":"ops","text":"deploy window opens at 14:00","id":"msg-channel-deploy"},{"op":"list_messages","channel":"ops","limit":10}]},"expected":{"ok":true,"messageExists":[{"channel":"ops","text":"deploy window opens at 14:00","from":"Ada"}],"contentIncludes":["deploy window opens at 14:00"],"toolCallsInclude":["post_message","list_messages"],"must":["Preserve the posted channel message text and author."],"mustNot":["Hide the channel message from list_messages."],"humanReviewRequired":false},"tags":["messaging","channel"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}],"channels":[{"name":"ops","members":["Ada","Ben"]}]}} +{"id":"messaging.dm-visible-to-recipient","suite":"messaging","executor":"relay","kind":"capability","input":{"message":"Send a direct message and let the recipient inspect their inbox.","operation":[{"op":"send_dm","as":"Ada","to":"Ben","text":"handoff note is ready","id":"msg-dm-handoff"},{"op":"check_inbox","as":"Ben"}]},"expected":{"ok":true,"messageExists":[{"kind":"dm","text":"handoff note is ready","from":"Ada"}],"contentIncludes":["handoff note is ready"],"toolCallsInclude":["send_dm","check_inbox"],"must":["Deliver the direct message to the named recipient."],"mustNot":["Require a channel membership for direct delivery."],"humanReviewRequired":false},"tags":["messaging","dm"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}]}} +{"id":"messaging.group-dm-members-only","suite":"messaging","executor":"relay","kind":"regression","input":{"message":"Create a named group DM and verify only participants see the message.","operation":[{"op":"send_group_dm","as":"Ada","participants":["Ben","Cy"],"name":"handoff-room","text":"triage notes for Ben and Cy","id":"msg-group-triage"},{"op":"check_inbox","as":"Ben"},{"op":"check_inbox","as":"Cy"}]},"expected":{"ok":true,"messageExists":[{"kind":"group_dm","text":"triage notes for Ben and Cy","from":"Ada"}],"contentIncludes":["triage notes for Ben and Cy"],"must":["Include every named participant in the group DM conversation."],"mustNot":["Convert a group DM into a public channel post."],"humanReviewRequired":false},"tags":["messaging","group-dm"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"},{"name":"Cy","type":"agent"}]}} +{"id":"messaging.channel-attachment-preserved","suite":"messaging","executor":"relay","kind":"regression","input":{"message":"Post a channel message with an attachment reference and list it back.","operation":[{"op":"post_message","as":"Ada","channel":"design","text":"wireframe attached","id":"msg-attachment-wireframe","attachments":["file-wireframe-1"]},{"op":"list_messages","channel":"design","limit":5}]},"expected":{"ok":true,"messageExists":[{"channel":"design","text":"wireframe attached","from":"Ada"}],"contentIncludes":["file-wireframe-1"],"must":["Preserve attachment identifiers on channel messages."],"mustNot":["Drop attachments when returning list_messages results."],"humanReviewRequired":false},"tags":["messaging","channel","attachments"],"mock":{"agents":[{"name":"Ada","type":"agent"}],"channels":[{"name":"design","members":["Ada"]}]}} +{"id":"messaging.idempotent-channel-post","suite":"messaging","executor":"relay","kind":"regression","input":{"message":"Retry a channel post with the same idempotency key and keep one logical message.","operation":[{"op":"post_message","as":"Ada","channel":"ops","text":"same deployment update","id":"msg-idempotent-deploy","idempotencyKey":"deploy-42"},{"op":"post_message","as":"Ada","channel":"ops","text":"same deployment update","id":"msg-idempotent-deploy-retry","idempotencyKey":"deploy-42"},{"op":"list_messages","channel":"ops","limit":10}]},"expected":{"ok":true,"messageExists":[{"channel":"ops","text":"same deployment update","from":"Ada"}],"contentIncludes":["same deployment update"],"must":["Treat repeated sends with the same idempotency key as one logical delivery."],"mustNot":["Produce duplicate user-visible channel messages for one idempotency key."],"humanReviewRequired":false},"tags":["messaging","idempotency"],"mock":{"agents":[{"name":"Ada","type":"agent"}],"channels":[{"name":"ops","members":["Ada"]}]}} diff --git a/evals/suites/messaging/cases.md b/evals/suites/messaging/cases.md new file mode 100644 index 000000000..2c0a4672c --- /dev/null +++ b/evals/suites/messaging/cases.md @@ -0,0 +1,217 @@ +# Messaging Cases + +Messaging cases cover direct, channel, and group delivery through the relay +message surfaces. + +## messaging.channel-post-visible +Executor: relay +Kind: capability +Tags: messaging, channel +Human Review: false + +### Message +Post a channel update and list the channel messages. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada", "Ben"] } + ] +} +``` + +### Operations +```json +[ + { "op": "post_message", "as": "Ada", "channel": "ops", "text": "deploy window opens at 14:00", "id": "msg-channel-deploy" }, + { "op": "list_messages", "channel": "ops", "limit": 10 } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"channel":"ops","text":"deploy window opens at 14:00","from":"Ada"} +contentIncludes: +- deploy window opens at 14:00 +toolCallsInclude: +- post_message +- list_messages +must: +- Preserve the posted channel message text and author. +mustNot: +- Hide the channel message from list_messages. + +## messaging.dm-visible-to-recipient +Executor: relay +Kind: capability +Tags: messaging, dm +Human Review: false + +### Message +Send a direct message and let the recipient inspect their inbox. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { "op": "send_dm", "as": "Ada", "to": "Ben", "text": "handoff note is ready", "id": "msg-dm-handoff" }, + { "op": "check_inbox", "as": "Ben" } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"kind":"dm","text":"handoff note is ready","from":"Ada"} +contentIncludes: +- handoff note is ready +toolCallsInclude: +- send_dm +- check_inbox +must: +- Deliver the direct message to the named recipient. +mustNot: +- Require a channel membership for direct delivery. + +## messaging.group-dm-members-only +Executor: relay +Kind: regression +Tags: messaging, group-dm +Human Review: false + +### Message +Create a named group DM and verify only participants see the message. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" }, + { "name": "Cy", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { "op": "send_group_dm", "as": "Ada", "participants": ["Ben", "Cy"], "name": "handoff-room", "text": "triage notes for Ben and Cy", "id": "msg-group-triage" }, + { "op": "check_inbox", "as": "Ben" }, + { "op": "check_inbox", "as": "Cy" } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"kind":"group_dm","text":"triage notes for Ben and Cy","from":"Ada"} +contentIncludes: +- triage notes for Ben and Cy +must: +- Include every named participant in the group DM conversation. +mustNot: +- Convert a group DM into a public channel post. + +## messaging.channel-attachment-preserved +Executor: relay +Kind: regression +Tags: messaging, channel, attachments +Human Review: false + +### Message +Post a channel message with an attachment reference and list it back. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" } + ], + "channels": [ + { "name": "design", "members": ["Ada"] } + ] +} +``` + +### Operations +```json +[ + { + "op": "post_message", + "as": "Ada", + "channel": "design", + "text": "wireframe attached", + "id": "msg-attachment-wireframe", + "attachments": ["file-wireframe-1"] + }, + { "op": "list_messages", "channel": "design", "limit": 5 } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"channel":"design","text":"wireframe attached","from":"Ada"} +contentIncludes: +- file-wireframe-1 +must: +- Preserve attachment identifiers on channel messages. +mustNot: +- Drop attachments when returning list_messages results. + +## messaging.idempotent-channel-post +Executor: relay +Kind: regression +Tags: messaging, idempotency +Human Review: false + +### Message +Retry a channel post with the same idempotency key and keep one logical message. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada"] } + ] +} +``` + +### Operations +```json +[ + { "op": "post_message", "as": "Ada", "channel": "ops", "text": "same deployment update", "id": "msg-idempotent-deploy", "idempotencyKey": "deploy-42" }, + { "op": "post_message", "as": "Ada", "channel": "ops", "text": "same deployment update", "id": "msg-idempotent-deploy-retry", "idempotencyKey": "deploy-42" }, + { "op": "list_messages", "channel": "ops", "limit": 10 } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"channel":"ops","text":"same deployment update","from":"Ada"} +contentIncludes: +- same deployment update +must: +- Treat repeated sends with the same idempotency key as one logical delivery. +mustNot: +- Produce duplicate user-visible channel messages for one idempotency key. diff --git a/evals/suites/messaging/rubric.md b/evals/suites/messaging/rubric.md new file mode 100644 index 000000000..56be8afd0 --- /dev/null +++ b/evals/suites/messaging/rubric.md @@ -0,0 +1,6 @@ +# Messaging Rubric + +Messaging cases pass when channel posts, direct messages, and group DMs are +created through the canonical relay ops and become visible only through the +appropriate read surfaces. The important signal is durable message state: +author, text, target conversation, attachments, and idempotent retry behavior. diff --git a/evals/suites/protocol-framing/cases.jsonl b/evals/suites/protocol-framing/cases.jsonl new file mode 100644 index 000000000..3ecfffc9d --- /dev/null +++ b/evals/suites/protocol-framing/cases.jsonl @@ -0,0 +1,5 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"protocol-framing.channel-envelope","suite":"protocol-framing","executor":"relay","kind":"capability","input":{"message":"Post a channel message and verify the public envelope preserves sender, channel, and content.","operation":[{"op":"post_message","as":"Lead","channel":"general","text":"Protocol hello","id":"proto_msg_1"},{"op":"list_messages","channel":"general"}]},"expected":{"ok":true,"contentIncludes":["proto_msg_1","Protocol hello"],"messageExists":[{"channel":"general","text":"Protocol hello","from":"Lead"}],"eventEmitted":["messageCreated"],"toolCallsInclude":["post_message","list_messages"],"must":["Preserve the message id, sender, channel, and text in the observed envelope."],"mustNot":["Require a live broker to frame a channel message."],"humanReviewRequired":false},"tags":["protocol","framing","messaging"],"mock":{"agents":[{"name":"Lead","type":"human"}],"channels":[{"name":"general","members":["Lead"]}]}} +{"id":"protocol-framing.thread-envelope","suite":"protocol-framing","executor":"relay","kind":"regression","input":{"message":"Reply to a seeded message and verify the thread reply is counted against the parent.","operation":[{"op":"reply_to_thread","as":"Worker","parent":"proto_parent","text":"Reply frame","id":"proto_reply"},{"op":"get_thread","parent":"proto_parent"}]},"expected":{"ok":true,"contentIncludes":["proto_reply","Reply frame"],"threadReplyCount":[{"parent":"proto_parent","count":1}],"eventEmitted":["threadReply"],"toolCallsInclude":["reply_to_thread","get_thread"],"must":["Preserve the parent id and expose the reply through thread retrieval."],"mustNot":["Count the parent message as its own reply."],"humanReviewRequired":false},"tags":["protocol","framing","threads"],"mock":{"agents":[{"name":"Lead","type":"human"},{"name":"Worker","type":"agent"}],"channels":[{"name":"general","members":["Lead","Worker"]}],"messages":[{"id":"proto_parent","channel":"general","from":"Lead","text":"Parent"}]}} +{"id":"protocol-framing.action-event","suite":"protocol-framing","executor":"relay","kind":"capability","input":{"message":"Invoke an in-memory action and verify action completion is emitted in the observed protocol event stream.","operation":[{"op":"register_action","as":"handler","name":"echo","handlerFixture":"echo_text"},{"op":"invoke_action","as":"planner","name":"echo","input":{"text":"action frame"}}]},"expected":{"ok":true,"contentIncludes":["action frame","echoed"],"eventEmitted":["action.completed"],"toolCallsInclude":["register_action","invoke_action"],"must":["Surface action completion through the observed event contract."],"mustNot":["Treat action invocation as a broker-only capability."],"humanReviewRequired":false},"tags":["protocol","framing","actions"],"mock":{"agents":[{"name":"planner","type":"agent"},{"name":"handler","type":"agent"}]}} diff --git a/evals/suites/protocol-framing/cases.md b/evals/suites/protocol-framing/cases.md new file mode 100644 index 000000000..3fc7f7d18 --- /dev/null +++ b/evals/suites/protocol-framing/cases.md @@ -0,0 +1,148 @@ +# Protocol Framing Cases + +These reference cases prove the Relay eval harness can compile markdown cases, +execute SDK-shaped operations in memory, and assert protocol-visible state. + +## protocol-framing.channel-envelope +Executor: relay +Kind: capability +Tags: protocol, framing, messaging +Human Review: false + +### Message +Post a channel message and verify the public envelope preserves sender, channel, and content. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" } + ], + "channels": [ + { "name": "general", "members": ["Lead"] } + ] +} +``` + +### Operations +```json +[ + { "op": "post_message", "as": "Lead", "channel": "general", "text": "Protocol hello", "id": "proto_msg_1" }, + { "op": "list_messages", "channel": "general" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- proto_msg_1 +- Protocol hello +messageExists: +- {"channel":"general","text":"Protocol hello","from":"Lead"} +eventEmitted: +- messageCreated +toolCallsInclude: +- post_message +- list_messages + +### Must +- Preserve the message id, sender, channel, and text in the observed envelope. + +### Must Not +- Require a live broker to frame a channel message. + +## protocol-framing.thread-envelope +Executor: relay +Kind: regression +Tags: protocol, framing, threads +Human Review: false + +### Message +Reply to a seeded message and verify the thread reply is counted against the parent. + +### Mock +```json +{ + "agents": [ + { "name": "Lead", "type": "human" }, + { "name": "Worker", "type": "agent" } + ], + "channels": [ + { "name": "general", "members": ["Lead", "Worker"] } + ], + "messages": [ + { "id": "proto_parent", "channel": "general", "from": "Lead", "text": "Parent" } + ] +} +``` + +### Operations +```json +[ + { "op": "reply_to_thread", "as": "Worker", "parent": "proto_parent", "text": "Reply frame", "id": "proto_reply" }, + { "op": "get_thread", "parent": "proto_parent" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- proto_reply +- Reply frame +threadReplyCount: +- {"parent":"proto_parent","count":1} +eventEmitted: +- threadReply +toolCallsInclude: +- reply_to_thread +- get_thread + +### Must +- Preserve the parent id and expose the reply through thread retrieval. + +### Must Not +- Count the parent message as its own reply. + +## protocol-framing.action-event +Executor: relay +Kind: capability +Tags: protocol, framing, actions +Human Review: false + +### Message +Invoke an in-memory action and verify action completion is emitted in the observed protocol event stream. + +### Mock +```json +{ + "agents": [ + { "name": "planner", "type": "agent" }, + { "name": "handler", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { "op": "register_action", "as": "handler", "name": "echo", "handlerFixture": "echo_text" }, + { "op": "invoke_action", "as": "planner", "name": "echo", "input": { "text": "action frame" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- action frame +- echoed +eventEmitted: +- action.completed +toolCallsInclude: +- register_action +- invoke_action + +### Must +- Surface action completion through the observed event contract. + +### Must Not +- Treat action invocation as a broker-only capability. diff --git a/evals/suites/protocol-framing/rubric.md b/evals/suites/protocol-framing/rubric.md new file mode 100644 index 000000000..128d389b7 --- /dev/null +++ b/evals/suites/protocol-framing/rubric.md @@ -0,0 +1,10 @@ +# Protocol Framing Rubric + +A passing protocol-framing case must: + +- Exercise the Relay SDK executor without connecting to a live broker. +- Preserve stable protocol identifiers such as message ids, parent ids, action names, and event types. +- Populate `observed.content`, `observed.events`, and `observed.toolCalls` so deterministic checks can validate the run. +- Keep seeded state and operation-created state visible through the same in-memory SDK surfaces. + +Cases fail if they require a live broker, drop sender/channel/thread identity, or emit events that cannot be checked through the executor observed-result contract. diff --git a/evals/suites/reactions/cases.jsonl b/evals/suites/reactions/cases.jsonl new file mode 100644 index 000000000..dc1f4510b --- /dev/null +++ b/evals/suites/reactions/cases.jsonl @@ -0,0 +1,7 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"reactions.add-increments-count","suite":"reactions","executor":"relay","kind":"capability","input":{"message":"Add one reaction to a channel message.","operation":[{"op":"add_reaction","as":"Ben","messageId":"msg-react-target","emoji":"thumbsup"},{"op":"list_messages","channel":"ops","limit":5}]},"expected":{"ok":true,"reactionCount":[{"messageId":"msg-react-target","emoji":"thumbsup","count":1}],"contentIncludes":["thumbsup"],"toolCallsInclude":["add_reaction"],"must":["Record the reacting identity once for the emoji."],"mustNot":["Require message authorship to add a reaction."],"humanReviewRequired":false},"tags":["reactions","counts"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}],"channels":[{"name":"ops","members":["Ada","Ben"]}],"messages":[{"id":"msg-react-target","channel":"ops","from":"Ada","text":"ready for review"}]}} +{"id":"reactions.add-is-idempotent-per-agent","suite":"reactions","executor":"relay","kind":"regression","input":{"message":"Add the same reaction twice from one agent and keep the count stable.","operation":[{"op":"add_reaction","as":"Ben","messageId":"msg-react-idem","emoji":"eyes"},{"op":"add_reaction","as":"Ben","messageId":"msg-react-idem","emoji":"eyes"},{"op":"list_messages","channel":"ops","limit":5}]},"expected":{"ok":true,"reactionCount":[{"messageId":"msg-react-idem","emoji":"eyes","count":1}],"must":["Treat duplicate reactions by the same agent as idempotent."],"mustNot":["Double-count one agent for the same emoji on the same message."],"humanReviewRequired":false},"tags":["reactions","idempotency"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}],"channels":[{"name":"ops","members":["Ada","Ben"]}],"messages":[{"id":"msg-react-idem","channel":"ops","from":"Ada","text":"ship candidate"}]}} +{"id":"reactions.multiple-agents-counted","suite":"reactions","executor":"relay","kind":"capability","input":{"message":"Add the same emoji from two agents and count both identities.","operation":[{"op":"add_reaction","as":"Ben","messageId":"msg-react-multi","emoji":"white_check_mark"},{"op":"add_reaction","as":"Cy","messageId":"msg-react-multi","emoji":"white_check_mark"},{"op":"list_messages","channel":"planning","limit":5}]},"expected":{"ok":true,"reactionCount":[{"messageId":"msg-react-multi","emoji":"white_check_mark","count":2}],"contentIncludes":["white_check_mark"],"must":["Count distinct reacting agents for the same emoji."],"mustNot":["Collapse reactions from different agents into one count."],"humanReviewRequired":false},"tags":["reactions","counts"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"},{"name":"Cy","type":"agent"}],"channels":[{"name":"planning","members":["Ada","Ben","Cy"]}],"messages":[{"id":"msg-react-multi","channel":"planning","from":"Ada","text":"proposal ready"}]}} +{"id":"reactions.remove-decrements-count","suite":"reactions","executor":"relay","kind":"regression","input":{"message":"Remove an existing reaction and verify the count is updated.","operation":[{"op":"remove_reaction","as":"Ben","messageId":"msg-react-remove","emoji":"eyes"},{"op":"list_messages","channel":"ops","limit":5}]},"expected":{"ok":true,"reactionCount":[{"messageId":"msg-react-remove","emoji":"eyes","count":0}],"toolCallsInclude":["remove_reaction"],"must":["Remove the actor from the emoji reaction set."],"mustNot":["Leave an empty reaction as a positive count."],"humanReviewRequired":false},"tags":["reactions","remove"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}],"channels":[{"name":"ops","members":["Ada","Ben"]}],"messages":[{"id":"msg-react-remove","channel":"ops","from":"Ada","text":"remove stale reaction","reactions":{"eyes":["Ben"]}}]}} +{"id":"reactions.remove-missing-is-idempotent","suite":"reactions","executor":"relay","kind":"regression","input":{"message":"Remove a reaction that is already absent without failing the run.","operation":[{"op":"remove_reaction","as":"Ben","messageId":"msg-react-absent","emoji":"eyes"},{"op":"list_messages","channel":"ops","limit":5}]},"expected":{"ok":true,"reactionCount":[{"messageId":"msg-react-absent","emoji":"eyes","count":0}],"must":["Allow repeated remove operations without throwing."],"mustNot":["Create a negative or placeholder reaction count."],"humanReviewRequired":false},"tags":["reactions","idempotency","remove"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}],"channels":[{"name":"ops","members":["Ada","Ben"]}],"messages":[{"id":"msg-react-absent","channel":"ops","from":"Ada","text":"no reaction yet"}]}} diff --git a/evals/suites/reactions/cases.md b/evals/suites/reactions/cases.md new file mode 100644 index 000000000..36389c3bf --- /dev/null +++ b/evals/suites/reactions/cases.md @@ -0,0 +1,230 @@ +# Reactions Cases + +Reaction cases cover add/remove behavior, counts, and repeated operations. + +## reactions.add-increments-count +Executor: relay +Kind: capability +Tags: reactions, counts +Human Review: false + +### Message +Add one reaction to a channel message. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada", "Ben"] } + ], + "messages": [ + { "id": "msg-react-target", "channel": "ops", "from": "Ada", "text": "ready for review" } + ] +} +``` + +### Operations +```json +[ + { "op": "add_reaction", "as": "Ben", "messageId": "msg-react-target", "emoji": "thumbsup" }, + { "op": "list_messages", "channel": "ops", "limit": 5 } +] +``` + +### Deterministic Checks +ok: true +reactionCount: +- {"messageId":"msg-react-target","emoji":"thumbsup","count":1} +contentIncludes: +- thumbsup +toolCallsInclude: +- add_reaction +must: +- Record the reacting identity once for the emoji. +mustNot: +- Require message authorship to add a reaction. + +## reactions.add-is-idempotent-per-agent +Executor: relay +Kind: regression +Tags: reactions, idempotency +Human Review: false + +### Message +Add the same reaction twice from one agent and keep the count stable. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada", "Ben"] } + ], + "messages": [ + { "id": "msg-react-idem", "channel": "ops", "from": "Ada", "text": "ship candidate" } + ] +} +``` + +### Operations +```json +[ + { "op": "add_reaction", "as": "Ben", "messageId": "msg-react-idem", "emoji": "eyes" }, + { "op": "add_reaction", "as": "Ben", "messageId": "msg-react-idem", "emoji": "eyes" }, + { "op": "list_messages", "channel": "ops", "limit": 5 } +] +``` + +### Deterministic Checks +ok: true +reactionCount: +- {"messageId":"msg-react-idem","emoji":"eyes","count":1} +must: +- Treat duplicate reactions by the same agent as idempotent. +mustNot: +- Double-count one agent for the same emoji on the same message. + +## reactions.multiple-agents-counted +Executor: relay +Kind: capability +Tags: reactions, counts +Human Review: false + +### Message +Add the same emoji from two agents and count both identities. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" }, + { "name": "Cy", "type": "agent" } + ], + "channels": [ + { "name": "planning", "members": ["Ada", "Ben", "Cy"] } + ], + "messages": [ + { "id": "msg-react-multi", "channel": "planning", "from": "Ada", "text": "proposal ready" } + ] +} +``` + +### Operations +```json +[ + { "op": "add_reaction", "as": "Ben", "messageId": "msg-react-multi", "emoji": "white_check_mark" }, + { "op": "add_reaction", "as": "Cy", "messageId": "msg-react-multi", "emoji": "white_check_mark" }, + { "op": "list_messages", "channel": "planning", "limit": 5 } +] +``` + +### Deterministic Checks +ok: true +reactionCount: +- {"messageId":"msg-react-multi","emoji":"white_check_mark","count":2} +contentIncludes: +- white_check_mark +must: +- Count distinct reacting agents for the same emoji. +mustNot: +- Collapse reactions from different agents into one count. + +## reactions.remove-decrements-count +Executor: relay +Kind: regression +Tags: reactions, remove +Human Review: false + +### Message +Remove an existing reaction and verify the count is updated. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada", "Ben"] } + ], + "messages": [ + { + "id": "msg-react-remove", + "channel": "ops", + "from": "Ada", + "text": "remove stale reaction", + "reactions": { "eyes": ["Ben"] } + } + ] +} +``` + +### Operations +```json +[ + { "op": "remove_reaction", "as": "Ben", "messageId": "msg-react-remove", "emoji": "eyes" }, + { "op": "list_messages", "channel": "ops", "limit": 5 } +] +``` + +### Deterministic Checks +ok: true +reactionCount: +- {"messageId":"msg-react-remove","emoji":"eyes","count":0} +toolCallsInclude: +- remove_reaction +must: +- Remove the actor from the emoji reaction set. +mustNot: +- Leave an empty reaction as a positive count. + +## reactions.remove-missing-is-idempotent +Executor: relay +Kind: regression +Tags: reactions, idempotency, remove +Human Review: false + +### Message +Remove a reaction that is already absent without failing the run. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada", "Ben"] } + ], + "messages": [ + { "id": "msg-react-absent", "channel": "ops", "from": "Ada", "text": "no reaction yet" } + ] +} +``` + +### Operations +```json +[ + { "op": "remove_reaction", "as": "Ben", "messageId": "msg-react-absent", "emoji": "eyes" }, + { "op": "list_messages", "channel": "ops", "limit": 5 } +] +``` + +### Deterministic Checks +ok: true +reactionCount: +- {"messageId":"msg-react-absent","emoji":"eyes","count":0} +must: +- Allow repeated remove operations without throwing. +mustNot: +- Create a negative or placeholder reaction count. diff --git a/evals/suites/reactions/rubric.md b/evals/suites/reactions/rubric.md new file mode 100644 index 000000000..6d396a10f --- /dev/null +++ b/evals/suites/reactions/rubric.md @@ -0,0 +1,5 @@ +# Reactions Rubric + +Reaction cases pass when emoji state is tracked per message and per reacting +agent. Add and remove operations must be idempotent for the same actor, while +distinct actors still contribute distinct counts. diff --git a/evals/suites/read-receipts/cases.jsonl b/evals/suites/read-receipts/cases.jsonl new file mode 100644 index 000000000..00b6d8cd2 --- /dev/null +++ b/evals/suites/read-receipts/cases.jsonl @@ -0,0 +1,6 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"read-receipts.dm-unread-then-read","suite":"read-receipts","executor":"relay","kind":"capability","input":{"message":"Receive a DM, mark it read, and verify the reader list.","operation":[{"op":"send_dm","as":"Ada","to":"Ben","text":"please review the handoff","id":"msg-read-dm"},{"op":"check_inbox","as":"Ben"},{"op":"mark_read","as":"Ben","messageId":"msg-read-dm"},{"op":"get_readers","messageId":"msg-read-dm"}]},"expected":{"ok":true,"contentIncludes":["please review the handoff","Ben"],"must":["Show the DM as unread before mark_read and read by Ben after mark_read."],"mustNot":["Mark the message read for unrelated agents."],"humanReviewRequired":false},"tags":["read-receipts","inbox","dm"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}]}} +{"id":"read-receipts.channel-message-readers","suite":"read-receipts","executor":"relay","kind":"capability","input":{"message":"Mark a channel message read by two members and list readers.","operation":[{"op":"mark_read","as":"Ben","messageId":"msg-read-channel"},{"op":"mark_read","as":"Cy","messageId":"msg-read-channel"},{"op":"get_readers","messageId":"msg-read-channel"}]},"expected":{"ok":true,"contentIncludes":["Ben","Cy"],"must":["Return every agent that explicitly marked the message read."],"mustNot":["Report non-reading members as readers."],"humanReviewRequired":false},"tags":["read-receipts","channel"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"},{"name":"Cy","type":"agent"}],"channels":[{"name":"ops","members":["Ada","Ben","Cy"]}],"messages":[{"id":"msg-read-channel","channel":"ops","from":"Ada","text":"channel read target"}]}} +{"id":"read-receipts.mark-read-idempotent","suite":"read-receipts","executor":"relay","kind":"regression","input":{"message":"Mark the same message read twice and keep one reader entry.","operation":[{"op":"mark_read","as":"Ben","messageId":"msg-read-idem"},{"op":"mark_read","as":"Ben","messageId":"msg-read-idem"},{"op":"get_readers","messageId":"msg-read-idem"}]},"expected":{"ok":true,"contentIncludes":["Ben"],"must":["Deduplicate repeated read receipts from the same agent."],"mustNot":["Return duplicate reader entries for one agent."],"humanReviewRequired":false},"tags":["read-receipts","idempotency"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}],"channels":[{"name":"ops","members":["Ada","Ben"]}],"messages":[{"id":"msg-read-idem","channel":"ops","from":"Ada","text":"read once target"}]}} +{"id":"read-receipts.inbox-clears-after-read","suite":"read-receipts","executor":"relay","kind":"regression","input":{"message":"Check the inbox after marking the only unread message read.","operation":[{"op":"send_dm","as":"Ada","to":"Ben","text":"clear this unread item","id":"msg-inbox-clear"},{"op":"mark_read","as":"Ben","messageId":"msg-inbox-clear"},{"op":"check_inbox","as":"Ben"}]},"expected":{"ok":true,"contentIncludes":["msg-inbox-clear"],"must":["Reflect that the message has transitioned out of Ben's unread inbox."],"mustNot":["Continue reporting the message as unread after mark_read."],"humanReviewRequired":false},"tags":["read-receipts","inbox"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}]}} diff --git a/evals/suites/read-receipts/cases.md b/evals/suites/read-receipts/cases.md new file mode 100644 index 000000000..8f3aa29c3 --- /dev/null +++ b/evals/suites/read-receipts/cases.md @@ -0,0 +1,167 @@ +# Read Receipts Cases + +Read receipt cases cover inbox transitions, explicit reads, and reader lookup. + +## read-receipts.dm-unread-then-read +Executor: relay +Kind: capability +Tags: read-receipts, inbox, dm +Human Review: false + +### Message +Receive a DM, mark it read, and verify the reader list. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { "op": "send_dm", "as": "Ada", "to": "Ben", "text": "please review the handoff", "id": "msg-read-dm" }, + { "op": "check_inbox", "as": "Ben" }, + { "op": "mark_read", "as": "Ben", "messageId": "msg-read-dm" }, + { "op": "get_readers", "messageId": "msg-read-dm" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- please review the handoff +- Ben +must: +- Show the DM as unread before mark_read and read by Ben after mark_read. +mustNot: +- Mark the message read for unrelated agents. + +## read-receipts.channel-message-readers +Executor: relay +Kind: capability +Tags: read-receipts, channel +Human Review: false + +### Message +Mark a channel message read by two members and list readers. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" }, + { "name": "Cy", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada", "Ben", "Cy"] } + ], + "messages": [ + { "id": "msg-read-channel", "channel": "ops", "from": "Ada", "text": "channel read target" } + ] +} +``` + +### Operations +```json +[ + { "op": "mark_read", "as": "Ben", "messageId": "msg-read-channel" }, + { "op": "mark_read", "as": "Cy", "messageId": "msg-read-channel" }, + { "op": "get_readers", "messageId": "msg-read-channel" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- Ben +- Cy +must: +- Return every agent that explicitly marked the message read. +mustNot: +- Report non-reading members as readers. + +## read-receipts.mark-read-idempotent +Executor: relay +Kind: regression +Tags: read-receipts, idempotency +Human Review: false + +### Message +Mark the same message read twice and keep one reader entry. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada", "Ben"] } + ], + "messages": [ + { "id": "msg-read-idem", "channel": "ops", "from": "Ada", "text": "read once target" } + ] +} +``` + +### Operations +```json +[ + { "op": "mark_read", "as": "Ben", "messageId": "msg-read-idem" }, + { "op": "mark_read", "as": "Ben", "messageId": "msg-read-idem" }, + { "op": "get_readers", "messageId": "msg-read-idem" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- Ben +must: +- Deduplicate repeated read receipts from the same agent. +mustNot: +- Return duplicate reader entries for one agent. + +## read-receipts.inbox-clears-after-read +Executor: relay +Kind: regression +Tags: read-receipts, inbox +Human Review: false + +### Message +Check the inbox after marking the only unread message read. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ] +} +``` + +### Operations +```json +[ + { "op": "send_dm", "as": "Ada", "to": "Ben", "text": "clear this unread item", "id": "msg-inbox-clear" }, + { "op": "mark_read", "as": "Ben", "messageId": "msg-inbox-clear" }, + { "op": "check_inbox", "as": "Ben" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- msg-inbox-clear +must: +- Reflect that the message has transitioned out of Ben's unread inbox. +mustNot: +- Continue reporting the message as unread after mark_read. diff --git a/evals/suites/read-receipts/rubric.md b/evals/suites/read-receipts/rubric.md new file mode 100644 index 000000000..fbfa3cf7a --- /dev/null +++ b/evals/suites/read-receipts/rubric.md @@ -0,0 +1,6 @@ +# Read Receipts Rubric + +Read receipt cases pass when unread inbox state changes only through delivery +and explicit mark_read operations, and get_readers returns deduplicated readers +for the target message. Failures point to broken read/unread transitions or +receipt attribution. diff --git a/evals/suites/search/cases.jsonl b/evals/suites/search/cases.jsonl new file mode 100644 index 000000000..dd0ef2522 --- /dev/null +++ b/evals/suites/search/cases.jsonl @@ -0,0 +1,7 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"search.finds-channel-message","suite":"search","executor":"relay","kind":"capability","input":{"message":"Search for a unique term in a channel message.","operation":[{"op":"search_messages","query":"bluebird","channel":"ops"}]},"expected":{"ok":true,"contentIncludes":["bluebird release marker"],"must":["Return messages matching the query text in the requested channel."],"mustNot":["Return unrelated channel messages as positive search hits."],"humanReviewRequired":false},"tags":["search","messages"],"mock":{"agents":[{"name":"Ada","type":"agent"}],"channels":[{"name":"ops","members":["Ada"]}],"messages":[{"id":"msg-search-needle","channel":"ops","from":"Ada","text":"bluebird release marker"},{"id":"msg-search-other","channel":"ops","from":"Ada","text":"ordinary deployment note"}]}} +{"id":"search.channel-scope-excludes-other-channels","suite":"search","executor":"relay","kind":"regression","input":{"message":"Search a single channel when another channel has the same term.","operation":[{"op":"search_messages","query":"phoenix","channel":"ops"}]},"expected":{"ok":true,"contentIncludes":["phoenix status in ops"],"must":["Honor the channel scope argument."],"mustNot":["Include random channel results in an ops-scoped search."],"humanReviewRequired":false},"tags":["search","channel-scope"],"mock":{"agents":[{"name":"Ada","type":"agent"}],"channels":[{"name":"ops","members":["Ada"]},{"name":"random","members":["Ada"]}],"messages":[{"id":"msg-search-ops","channel":"ops","from":"Ada","text":"phoenix status in ops"},{"id":"msg-search-random","channel":"random","from":"Ada","text":"phoenix status in random"}]}} +{"id":"search.cross-channel-finds-all","suite":"search","executor":"relay","kind":"capability","input":{"message":"Search without a channel filter and return matches across channels.","operation":[{"op":"search_messages","query":"atlas migration"}]},"expected":{"ok":true,"contentIncludes":["atlas migration ops note","atlas migration planning note"],"must":["Search all accessible channels when no channel filter is provided."],"mustNot":["Stop at the first matching channel."],"humanReviewRequired":false},"tags":["search","global"],"mock":{"agents":[{"name":"Ada","type":"agent"}],"channels":[{"name":"ops","members":["Ada"]},{"name":"planning","members":["Ada"]}],"messages":[{"id":"msg-search-global-ops","channel":"ops","from":"Ada","text":"atlas migration ops note"},{"id":"msg-search-global-plan","channel":"planning","from":"Ada","text":"atlas migration planning note"}]}} +{"id":"search.list-messages-respects-limit","suite":"search","executor":"relay","kind":"regression","input":{"message":"List only the requested number of channel messages.","operation":[{"op":"list_messages","channel":"ops","limit":2}]},"expected":{"ok":true,"contentIncludes":["old status","middle status"],"must":["Respect the requested list_messages limit."],"mustNot":["Return messages beyond the requested limit."],"humanReviewRequired":false},"tags":["search","list-messages","pagination"],"mock":{"agents":[{"name":"Ada","type":"agent"}],"channels":[{"name":"ops","members":["Ada"]}],"messages":[{"id":"msg-list-old","channel":"ops","from":"Ada","text":"old status"},{"id":"msg-list-mid","channel":"ops","from":"Ada","text":"middle status"},{"id":"msg-list-new","channel":"ops","from":"Ada","text":"new status"}]}} +{"id":"search.new-message-searchable","suite":"search","executor":"relay","kind":"regression","input":{"message":"Post a new channel message and immediately search for it.","operation":[{"op":"post_message","as":"Ada","channel":"ops","text":"instant-search-token ready","id":"msg-search-fresh"},{"op":"search_messages","query":"instant-search-token","channel":"ops"}]},"expected":{"ok":true,"messageExists":[{"channel":"ops","text":"instant-search-token ready","from":"Ada"}],"contentIncludes":["instant-search-token ready"],"toolCallsInclude":["post_message","search_messages"],"must":["Make newly posted messages searchable in the same run."],"mustNot":["Require a separate indexing or refresh operation."],"humanReviewRequired":false},"tags":["search","indexing"],"mock":{"agents":[{"name":"Ada","type":"agent"}],"channels":[{"name":"ops","members":["Ada"]}]}} diff --git a/evals/suites/search/cases.md b/evals/suites/search/cases.md new file mode 100644 index 000000000..60e27c54a --- /dev/null +++ b/evals/suites/search/cases.md @@ -0,0 +1,215 @@ +# Search Cases + +Search cases cover channel listing, scoped search, and cross-channel search. + +## search.finds-channel-message +Executor: relay +Kind: capability +Tags: search, messages +Human Review: false + +### Message +Search for a unique term in a channel message. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada"] } + ], + "messages": [ + { "id": "msg-search-needle", "channel": "ops", "from": "Ada", "text": "bluebird release marker" }, + { "id": "msg-search-other", "channel": "ops", "from": "Ada", "text": "ordinary deployment note" } + ] +} +``` + +### Operations +```json +[ + { "op": "search_messages", "query": "bluebird", "channel": "ops" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- bluebird release marker +must: +- Return messages matching the query text in the requested channel. +mustNot: +- Return unrelated channel messages as positive search hits. + +## search.channel-scope-excludes-other-channels +Executor: relay +Kind: regression +Tags: search, channel-scope +Human Review: false + +### Message +Search a single channel when another channel has the same term. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada"] }, + { "name": "random", "members": ["Ada"] } + ], + "messages": [ + { "id": "msg-search-ops", "channel": "ops", "from": "Ada", "text": "phoenix status in ops" }, + { "id": "msg-search-random", "channel": "random", "from": "Ada", "text": "phoenix status in random" } + ] +} +``` + +### Operations +```json +[ + { "op": "search_messages", "query": "phoenix", "channel": "ops" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- phoenix status in ops +must: +- Honor the channel scope argument. +mustNot: +- Include random channel results in an ops-scoped search. + +## search.cross-channel-finds-all +Executor: relay +Kind: capability +Tags: search, global +Human Review: false + +### Message +Search without a channel filter and return matches across channels. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada"] }, + { "name": "planning", "members": ["Ada"] } + ], + "messages": [ + { "id": "msg-search-global-ops", "channel": "ops", "from": "Ada", "text": "atlas migration ops note" }, + { "id": "msg-search-global-plan", "channel": "planning", "from": "Ada", "text": "atlas migration planning note" } + ] +} +``` + +### Operations +```json +[ + { "op": "search_messages", "query": "atlas migration" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- atlas migration ops note +- atlas migration planning note +must: +- Search all accessible channels when no channel filter is provided. +mustNot: +- Stop at the first matching channel. + +## search.list-messages-respects-limit +Executor: relay +Kind: regression +Tags: search, list-messages, pagination +Human Review: false + +### Message +List only the requested number of channel messages. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada"] } + ], + "messages": [ + { "id": "msg-list-old", "channel": "ops", "from": "Ada", "text": "old status" }, + { "id": "msg-list-mid", "channel": "ops", "from": "Ada", "text": "middle status" }, + { "id": "msg-list-new", "channel": "ops", "from": "Ada", "text": "new status" } + ] +} +``` + +### Operations +```json +[ + { "op": "list_messages", "channel": "ops", "limit": 2 } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- old status +- middle status +must: +- Respect the requested list_messages limit. +mustNot: +- Return messages beyond the requested limit. + +## search.new-message-searchable +Executor: relay +Kind: regression +Tags: search, indexing +Human Review: false + +### Message +Post a new channel message and immediately search for it. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada"] } + ] +} +``` + +### Operations +```json +[ + { "op": "post_message", "as": "Ada", "channel": "ops", "text": "instant-search-token ready", "id": "msg-search-fresh" }, + { "op": "search_messages", "query": "instant-search-token", "channel": "ops" } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"channel":"ops","text":"instant-search-token ready","from":"Ada"} +contentIncludes: +- instant-search-token ready +toolCallsInclude: +- post_message +- search_messages +must: +- Make newly posted messages searchable in the same run. +mustNot: +- Require a separate indexing or refresh operation. diff --git a/evals/suites/search/rubric.md b/evals/suites/search/rubric.md new file mode 100644 index 000000000..1a5683fb8 --- /dev/null +++ b/evals/suites/search/rubric.md @@ -0,0 +1,6 @@ +# Search Rubric + +Search cases pass when list_messages and search_messages expose the expected +conversation state with correct scoping, limits, and immediate visibility of +new messages. Failures mean callers cannot rely on relay search/list surfaces +for deterministic message discovery. diff --git a/evals/suites/session/cases.jsonl b/evals/suites/session/cases.jsonl new file mode 100644 index 000000000..2bee56183 --- /dev/null +++ b/evals/suites/session/cases.jsonl @@ -0,0 +1,10 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"session.define-harness-registerable-agent","suite":"session","executor":"relay","kind":"capability","input":{"message":"Define a review harness and create a registerable session agent with explicit input.","operation":[{"op":"define_harness","name":"review-bot","version":"1.0.0","input":{"name":"reviewer"}}]},"expected":{"ok":true,"contentIncludes":["\"kind\": \"session\"","\"name\": \"reviewer\"","\"harness:review-bot:reviewer\""],"toolCallsInclude":["define_harness"],"minToolCalls":1,"must":["Produce a session-kind agent handle without requiring a driver or broker.","Preserve the harness config and the caller-provided input."],"mustNot":["Drop listener predicate builders from the created agent handle."],"humanReviewRequired":false},"tags":["session","harness"],"mock":{"agents":[]}} +{"id":"session.next-harness-name-increments","suite":"session","executor":"relay","kind":"regression","input":{"message":"Generate default harness names repeatedly from the same base.","operation":[{"op":"next_harness_name","base":"task-bot"},{"op":"next_harness_name","base":"task-bot"},{"op":"next_harness_name","base":"task-bot"}]},"expected":{"ok":true,"contentIncludes":["task-bot","task-bot-2","task-bot-3"],"toolCallsInclude":["next_harness_name"],"minToolCalls":3,"must":["Keep the first generated name equal to the base.","Add numeric suffixes for later names from the same base."],"mustNot":["Reuse the same default name within one run."],"humanReviewRequired":false},"tags":["session","harness","naming"],"mock":{}} +{"id":"session.explicit-harness-name-wins","suite":"session","executor":"relay","kind":"regression","input":{"message":"Generate a harness name with an explicit override.","operation":[{"op":"next_harness_name","base":"task-bot","explicit":"named-reviewer"}]},"expected":{"ok":true,"contentIncludes":["named-reviewer"],"must":["Treat explicit harness input names as authoritative."],"mustNot":["Mutate the explicit string."],"humanReviewRequired":false},"tags":["session","harness","naming"],"mock":{}} +{"id":"session.normalize-identity-default-handle","suite":"session","executor":"relay","kind":"capability","input":{"message":"Normalize an identity with spaces and metadata but no explicit handle.","operation":[{"op":"normalize_identity","input":{"name":"Review Bot","displayName":"Review Bot","description":"reviews code","metadata":{"team":"evals"}}}]},"expected":{"ok":true,"contentIncludes":["\"@Review-Bot\"","\"displayName\": \"Review Bot\"","\"team\": \"evals\""],"toolCallsInclude":["normalize_identity"],"must":["Derive a sigiled handle from the name when no handle is provided.","Preserve optional display name, description, and metadata fields."],"mustNot":["Emit an empty id or handle."],"humanReviewRequired":false},"tags":["session","identity"],"mock":{}} +{"id":"session.normalize-identity-preserves-explicit-handle","suite":"session","executor":"relay","kind":"regression","input":{"message":"Normalize an identity that already has an id and handle.","operation":[{"op":"normalize_identity","input":{"id":"agent_reviewer","name":"reviewer","handle":"@reviewer"}}]},"expected":{"ok":true,"contentIncludes":["\"id\": \"agent_reviewer\"","\"handle\": \"@reviewer\""],"must":["Prefer provided identity fields over generated defaults."],"mustNot":["Strip the `@` sigil from an explicit handle."],"humanReviewRequired":false},"tags":["session","identity"],"mock":{}} +{"id":"session.format-handle-trims-and-sigils","suite":"session","executor":"relay","kind":"regression","input":{"message":"Format handles from plain names, already-sigiled names, and whitespace-only names.","operation":[{"op":"format_handle","name":" qa bot "},{"op":"format_handle","name":"@ready"},{"op":"format_handle","name":" "}]},"expected":{"ok":true,"contentIncludes":["\"@qa-bot\"","\"@ready\"","\"@agent\""],"toolCallsInclude":["format_handle"],"minToolCalls":3,"must":["Replace internal whitespace with hyphens.","Leave already-sigiled handles unchanged.","Fall back to `@agent` for blank input."],"mustNot":["Return a handle without an `@` sigil."],"humanReviewRequired":false},"tags":["session","identity","handle"],"mock":{}} +{"id":"session.read-minimal-capabilities","suite":"session","executor":"relay","kind":"capability","input":{"message":"Read the SDK minimal session capabilities contract.","operation":[{"op":"read_capabilities"}]},"expected":{"ok":true,"contentIncludes":["\"receive\": true","\"modes\": [","\"immediate\"","\"emits\": [","\"status.changed\"","\"release\": true"],"toolCallsInclude":["read_capabilities"],"must":["Expose the baseline receive, immediate delivery, status event, and release lifecycle capabilities."],"mustNot":["Claim unsupported lifecycle capabilities such as pause or fork in the minimal profile."],"humanReviewRequired":false},"tags":["session","capabilities"],"mock":{}} +{"id":"session.resume-session-emits-continuity","suite":"session","executor":"relay","kind":"capability","input":{"message":"Resume a previously known session and surface the continuity event.","operation":[{"op":"resume_session","agent":{"id":"agent_reviewer","name":"reviewer","handle":"@reviewer"},"reason":"executor restart","capabilities":{"lifecycle":{"resume":true}}}]},"expected":{"ok":true,"eventEmitted":["session.resumed"],"contentIncludes":["agent_reviewer","executor restart"],"toolCallsInclude":["resume_session"],"must":["Preserve the resumed agent identity.","Emit a `session.resumed` event with the supplied reason."],"mustNot":["Treat resume as a fresh unnamed session."],"humanReviewRequired":false},"tags":["session","resume","continuity"],"mock":{"agents":[{"name":"reviewer","type":"agent","id":"agent_reviewer","status":"offline"}]}} diff --git a/evals/suites/session/cases.md b/evals/suites/session/cases.md new file mode 100644 index 000000000..27d7d9dae --- /dev/null +++ b/evals/suites/session/cases.md @@ -0,0 +1,337 @@ +# Session Cases + +These cases pin the SDK session harness helpers that let agents define reusable +in-process harnesses, normalize identities, read baseline capabilities, and +resume continuity without a live broker. + +## session.define-harness-registerable-agent +Executor: relay +Kind: capability +Tags: session, harness +Human Review: false + +### Message +Define a review harness and create a registerable session agent with explicit input. + +### Mock +```json +{ + "agents": [] +} +``` + +### Operations +```json +[ + { "op": "define_harness", "name": "review-bot", "version": "1.0.0", "input": { "name": "reviewer" } } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- "kind": "session" +- "name": "reviewer" +- "harness:review-bot:reviewer" +toolCallsInclude: +- define_harness +minToolCalls: 1 + +### Must +- Produce a session-kind agent handle without requiring a driver or broker. +- Preserve the harness config and the caller-provided input. + +### Must Not +- Drop listener predicate builders from the created agent handle. + +## session.next-harness-name-increments +Executor: relay +Kind: regression +Tags: session, harness, naming +Human Review: false + +### Message +Generate default harness names repeatedly from the same base. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "next_harness_name", "base": "task-bot" }, + { "op": "next_harness_name", "base": "task-bot" }, + { "op": "next_harness_name", "base": "task-bot" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- task-bot +- task-bot-2 +- task-bot-3 +toolCallsInclude: +- next_harness_name +minToolCalls: 3 + +### Must +- Keep the first generated name equal to the base. +- Add numeric suffixes for later names from the same base. + +### Must Not +- Reuse the same default name within one run. + +## session.explicit-harness-name-wins +Executor: relay +Kind: regression +Tags: session, harness, naming +Human Review: false + +### Message +Generate a harness name with an explicit override. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "next_harness_name", "base": "task-bot", "explicit": "named-reviewer" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- named-reviewer +must: +- Return the explicit name unchanged. +mustNot: +- Append a numeric suffix when an explicit name is provided. + +### Must +- Treat explicit harness input names as authoritative. + +### Must Not +- Mutate the explicit string. + +## session.normalize-identity-default-handle +Executor: relay +Kind: capability +Tags: session, identity +Human Review: false + +### Message +Normalize an identity with spaces and metadata but no explicit handle. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { + "op": "normalize_identity", + "input": { + "name": "Review Bot", + "displayName": "Review Bot", + "description": "reviews code", + "metadata": { "team": "evals" } + } + } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- "@Review-Bot" +- "displayName": "Review Bot" +- "team": "evals" +toolCallsInclude: +- normalize_identity + +### Must +- Derive a sigiled handle from the name when no handle is provided. +- Preserve optional display name, description, and metadata fields. + +### Must Not +- Emit an empty id or handle. + +## session.normalize-identity-preserves-explicit-handle +Executor: relay +Kind: regression +Tags: session, identity +Human Review: false + +### Message +Normalize an identity that already has an id and handle. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { + "op": "normalize_identity", + "input": { + "id": "agent_reviewer", + "name": "reviewer", + "handle": "@reviewer" + } + } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- "id": "agent_reviewer" +- "handle": "@reviewer" +must: +- Preserve the explicit id and handle. +mustNot: +- Reformat a valid explicit handle. + +### Must +- Prefer provided identity fields over generated defaults. + +### Must Not +- Strip the `@` sigil from an explicit handle. + +## session.format-handle-trims-and-sigils +Executor: relay +Kind: regression +Tags: session, identity, handle +Human Review: false + +### Message +Format handles from plain names, already-sigiled names, and whitespace-only names. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "format_handle", "name": " qa bot " }, + { "op": "format_handle", "name": "@ready" }, + { "op": "format_handle", "name": " " } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- "@qa-bot" +- "@ready" +- "@agent" +toolCallsInclude: +- format_handle +minToolCalls: 3 + +### Must +- Replace internal whitespace with hyphens. +- Leave already-sigiled handles unchanged. +- Fall back to `@agent` for blank input. + +### Must Not +- Return a handle without an `@` sigil. + +## session.read-minimal-capabilities +Executor: relay +Kind: capability +Tags: session, capabilities +Human Review: false + +### Message +Read the SDK minimal session capabilities contract. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "read_capabilities" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- "receive": true +- "modes": [ +- "immediate" +- "emits": [ +- "status.changed" +- "release": true +toolCallsInclude: +- read_capabilities + +### Must +- Expose the baseline receive, immediate delivery, status event, and release lifecycle capabilities. + +### Must Not +- Claim unsupported lifecycle capabilities such as pause or fork in the minimal profile. + +## session.resume-session-emits-continuity +Executor: relay +Kind: capability +Tags: session, resume, continuity +Human Review: false + +### Message +Resume a previously known session and surface the continuity event. + +### Mock +```json +{ + "agents": [ + { "name": "reviewer", "type": "agent", "id": "agent_reviewer", "status": "offline" } + ] +} +``` + +### Operations +```json +[ + { + "op": "resume_session", + "agent": { "id": "agent_reviewer", "name": "reviewer", "handle": "@reviewer" }, + "reason": "executor restart", + "capabilities": { "lifecycle": { "resume": true } } + } +] +``` + +### Deterministic Checks +ok: true +eventEmitted: +- session.resumed +contentIncludes: +- agent_reviewer +- executor restart +toolCallsInclude: +- resume_session + +### Must +- Preserve the resumed agent identity. +- Emit a `session.resumed` event with the supplied reason. + +### Must Not +- Treat resume as a fresh unnamed session. diff --git a/evals/suites/session/rubric.md b/evals/suites/session/rubric.md new file mode 100644 index 000000000..4afea93fe --- /dev/null +++ b/evals/suites/session/rubric.md @@ -0,0 +1,7 @@ +# Session Rubric + +Session cases are deterministic. A passing run must show that harness helpers +produce registerable session agents, naming is stable and explicit names win, +identity normalization preserves caller intent, minimal capabilities match the +SDK contract, and resume operations emit continuity events without requiring a +live Relaycast broker. diff --git a/evals/suites/threads/cases.jsonl b/evals/suites/threads/cases.jsonl new file mode 100644 index 000000000..3b68569fa --- /dev/null +++ b/evals/suites/threads/cases.jsonl @@ -0,0 +1,6 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"threads.single-reply-counted","suite":"threads","executor":"relay","kind":"capability","input":{"message":"Reply to a seeded parent message and fetch the thread.","operation":[{"op":"reply_to_thread","as":"Ben","parent":"msg-parent-rollout","text":"database backup complete","id":"msg-reply-backup"},{"op":"get_thread","messageId":"msg-parent-rollout"}]},"expected":{"ok":true,"threadReplyCount":[{"parent":"msg-parent-rollout","count":1}],"messageExists":[{"channel":"ops","text":"database backup complete","from":"Ben"}],"contentIncludes":["rollout checklist","database backup complete"],"must":["Associate the reply with the parent thread."],"mustNot":["Count the parent message as its own reply."],"humanReviewRequired":false},"tags":["threads","replies"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}],"channels":[{"name":"ops","members":["Ada","Ben"]}],"messages":[{"id":"msg-parent-rollout","channel":"ops","from":"Ada","text":"rollout checklist"}]}} +{"id":"threads.multiple-replies-ordered","suite":"threads","executor":"relay","kind":"regression","input":{"message":"Add two replies from different agents and retrieve the full thread.","operation":[{"op":"reply_to_thread","as":"Ben","parent":"msg-parent-plan","text":"risks logged","id":"msg-reply-risks"},{"op":"reply_to_thread","as":"Cy","parent":"msg-parent-plan","text":"owners assigned","id":"msg-reply-owners"},{"op":"get_thread","messageId":"msg-parent-plan"}]},"expected":{"ok":true,"threadReplyCount":[{"parent":"msg-parent-plan","count":2}],"contentIncludes":["risks logged","owners assigned"],"toolCallsInclude":["reply_to_thread","get_thread"],"must":["Return every reply attached to the parent thread."],"mustNot":["Drop earlier replies when later replies are added."],"humanReviewRequired":false},"tags":["threads","ordering"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"},{"name":"Cy","type":"agent"}],"channels":[{"name":"planning","members":["Ada","Ben","Cy"]}],"messages":[{"id":"msg-parent-plan","channel":"planning","from":"Ada","text":"plan review"}]}} +{"id":"threads.parent-alias-supported","suite":"threads","executor":"relay","kind":"regression","input":{"message":"Fetch a thread using the get_thread parent alias confirmed by the harness.","operation":[{"op":"get_thread","parent":"msg-parent-incident"}]},"expected":{"ok":true,"threadReplyCount":[{"parent":"msg-parent-incident","count":1}],"contentIncludes":["incident root cause","cache invalidation confirmed"],"must":["Accept parent as an alias for get_thread messageId."],"mustNot":["Require callers to duplicate the parent id under both fields."],"humanReviewRequired":false},"tags":["threads","aliases"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}],"channels":[{"name":"support","members":["Ada","Ben"]}],"messages":[{"id":"msg-parent-incident","channel":"support","from":"Ada","text":"incident root cause"},{"id":"msg-seeded-reply","channel":"support","from":"Ben","text":"cache invalidation confirmed","threadParent":"msg-parent-incident"}]}} +{"id":"threads.channel-list-excludes-replies","suite":"threads","executor":"relay","kind":"regression","input":{"message":"List a channel after creating a thread reply and keep top-level listing clean.","operation":[{"op":"reply_to_thread","as":"Ben","parent":"msg-parent-check","text":"thread-only acknowledgement","id":"msg-reply-thread-only"},{"op":"list_messages","channel":"ops","limit":10},{"op":"get_thread","messageId":"msg-parent-check"}]},"expected":{"ok":true,"messageExists":[{"channel":"ops","text":"status check","from":"Ada"}],"threadReplyCount":[{"parent":"msg-parent-check","count":1}],"contentIncludes":["thread-only acknowledgement"],"must":["Keep thread replies discoverable through get_thread."],"mustNot":["Promote thread replies to independent top-level channel messages."],"humanReviewRequired":false},"tags":["threads","list-messages"],"mock":{"agents":[{"name":"Ada","type":"agent"},{"name":"Ben","type":"agent"}],"channels":[{"name":"ops","members":["Ada","Ben"]}],"messages":[{"id":"msg-parent-check","channel":"ops","from":"Ada","text":"status check"}]}} diff --git a/evals/suites/threads/cases.md b/evals/suites/threads/cases.md new file mode 100644 index 000000000..5f9b77af8 --- /dev/null +++ b/evals/suites/threads/cases.md @@ -0,0 +1,192 @@ +# Threads Cases + +Thread cases cover replies, thread retrieval, and deterministic reply counts. + +## threads.single-reply-counted +Executor: relay +Kind: capability +Tags: threads, replies +Human Review: false + +### Message +Reply to a seeded parent message and fetch the thread. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada", "Ben"] } + ], + "messages": [ + { "id": "msg-parent-rollout", "channel": "ops", "from": "Ada", "text": "rollout checklist" } + ] +} +``` + +### Operations +```json +[ + { "op": "reply_to_thread", "as": "Ben", "parent": "msg-parent-rollout", "text": "database backup complete", "id": "msg-reply-backup" }, + { "op": "get_thread", "messageId": "msg-parent-rollout" } +] +``` + +### Deterministic Checks +ok: true +threadReplyCount: +- {"parent":"msg-parent-rollout","count":1} +messageExists: +- {"channel":"ops","text":"database backup complete","from":"Ben"} +contentIncludes: +- rollout checklist +- database backup complete +must: +- Associate the reply with the parent thread. +mustNot: +- Count the parent message as its own reply. + +## threads.multiple-replies-ordered +Executor: relay +Kind: regression +Tags: threads, ordering +Human Review: false + +### Message +Add two replies from different agents and retrieve the full thread. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" }, + { "name": "Cy", "type": "agent" } + ], + "channels": [ + { "name": "planning", "members": ["Ada", "Ben", "Cy"] } + ], + "messages": [ + { "id": "msg-parent-plan", "channel": "planning", "from": "Ada", "text": "plan review" } + ] +} +``` + +### Operations +```json +[ + { "op": "reply_to_thread", "as": "Ben", "parent": "msg-parent-plan", "text": "risks logged", "id": "msg-reply-risks" }, + { "op": "reply_to_thread", "as": "Cy", "parent": "msg-parent-plan", "text": "owners assigned", "id": "msg-reply-owners" }, + { "op": "get_thread", "messageId": "msg-parent-plan" } +] +``` + +### Deterministic Checks +ok: true +threadReplyCount: +- {"parent":"msg-parent-plan","count":2} +contentIncludes: +- risks logged +- owners assigned +toolCallsInclude: +- reply_to_thread +- get_thread +must: +- Return every reply attached to the parent thread. +mustNot: +- Drop earlier replies when later replies are added. + +## threads.parent-alias-supported +Executor: relay +Kind: regression +Tags: threads, aliases +Human Review: false + +### Message +Fetch a thread using the get_thread parent alias confirmed by the harness. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ], + "channels": [ + { "name": "support", "members": ["Ada", "Ben"] } + ], + "messages": [ + { "id": "msg-parent-incident", "channel": "support", "from": "Ada", "text": "incident root cause" }, + { "id": "msg-seeded-reply", "channel": "support", "from": "Ben", "text": "cache invalidation confirmed", "threadParent": "msg-parent-incident" } + ] +} +``` + +### Operations +```json +[ + { "op": "get_thread", "parent": "msg-parent-incident" } +] +``` + +### Deterministic Checks +ok: true +threadReplyCount: +- {"parent":"msg-parent-incident","count":1} +contentIncludes: +- incident root cause +- cache invalidation confirmed +must: +- Accept parent as an alias for get_thread messageId. +mustNot: +- Require callers to duplicate the parent id under both fields. + +## threads.channel-list-excludes-replies +Executor: relay +Kind: regression +Tags: threads, list-messages +Human Review: false + +### Message +List a channel after creating a thread reply and keep top-level listing clean. + +### Mock +```json +{ + "agents": [ + { "name": "Ada", "type": "agent" }, + { "name": "Ben", "type": "agent" } + ], + "channels": [ + { "name": "ops", "members": ["Ada", "Ben"] } + ], + "messages": [ + { "id": "msg-parent-check", "channel": "ops", "from": "Ada", "text": "status check" } + ] +} +``` + +### Operations +```json +[ + { "op": "reply_to_thread", "as": "Ben", "parent": "msg-parent-check", "text": "thread-only acknowledgement", "id": "msg-reply-thread-only" }, + { "op": "list_messages", "channel": "ops", "limit": 10 }, + { "op": "get_thread", "messageId": "msg-parent-check" } +] +``` + +### Deterministic Checks +ok: true +messageExists: +- {"channel":"ops","text":"status check","from":"Ada"} +threadReplyCount: +- {"parent":"msg-parent-check","count":1} +contentIncludes: +- thread-only acknowledgement +must: +- Keep thread replies discoverable through get_thread. +mustNot: +- Promote thread replies to independent top-level channel messages. diff --git a/evals/suites/threads/rubric.md b/evals/suites/threads/rubric.md new file mode 100644 index 000000000..d1503013f --- /dev/null +++ b/evals/suites/threads/rubric.md @@ -0,0 +1,6 @@ +# Threads Rubric + +Thread cases pass when replies retain their parent linkage, thread fetches +include parent plus replies, and reply counts reflect only child messages. +Failures indicate regressions in threaded conversation structure, alias support, +or top-level channel listing behavior. diff --git a/evals/suites/workspaces/cases.jsonl b/evals/suites/workspaces/cases.jsonl new file mode 100644 index 000000000..55bb9e66a --- /dev/null +++ b/evals/suites/workspaces/cases.jsonl @@ -0,0 +1,7 @@ +# Generated by scripts/evals/compile-cases.mjs from cases.md. +# Do not edit this file directly; edit cases.md in this suite instead. +{"id":"workspaces.create-returns-usable-key","suite":"workspaces","executor":"relay","kind":"capability","input":{"message":"Create a new Relay workspace and verify the returned workspace key can be used by subsequent operations.","operation":[{"op":"create_workspace","name":"Eval Workspace","id":"ws_eval_create"},{"op":"register_agent","name":"Lead","type":"human"},{"op":"create_channel","as":"Lead","name":"created-workspace-room","topic":"Workspace smoke"},{"op":"list_channels","as":"Lead"}]},"expected":{"ok":true,"contentIncludes":["Eval Workspace","rk_live_","created-workspace-room"],"toolCallsInclude":["create_workspace","register_agent","create_channel"],"minToolCalls":4,"must":["Return a workspace key with the Relay workspace-key prefix.","Make the created workspace immediately usable by the registered agent."],"mustNot":["Require a separate set_workspace_key call after create_workspace succeeds."],"humanReviewRequired":false},"tags":["workspaces","create"],"mock":{}} +{"id":"workspaces.set-key-selects-existing-workspace","suite":"workspaces","executor":"relay","kind":"capability","input":{"message":"Set the SDK session to an existing workspace key and list that workspace's channels.","operation":[{"op":"set_workspace_key","workspaceKey":"rk_live_existing_eval"},{"op":"list_channels","as":"Lead"}]},"expected":{"ok":true,"contentIncludes":["Existing Workspace","existing-room","Existing topic"],"toolCallsInclude":["set_workspace_key","list_channels"],"must":["Select the seeded workspace by key.","Resolve subsequent reads against the selected workspace."],"mustNot":["Leak channels from any other workspace."],"humanReviewRequired":false},"tags":["workspaces","auth"],"mock":{"workspaces":[{"name":"Existing Workspace","key":"rk_live_existing_eval","agents":[{"name":"Lead","type":"human"}],"channels":[{"name":"existing-room","topic":"Existing topic","members":["Lead"]}]}]}} +{"id":"workspaces.switch-key-isolates-state","suite":"workspaces","executor":"relay","kind":"regression","input":{"message":"Switching workspace keys should isolate channel state between workspaces.","operation":[{"op":"set_workspace_key","workspaceKey":"rk_live_ws_one"},{"op":"list_channels","as":"Lead"},{"op":"set_workspace_key","workspaceKey":"rk_live_ws_two"},{"op":"list_channels","as":"Lead"}]},"expected":{"ok":true,"contentIncludes":["alpha-room","beta-room"],"forbidPhrases":["merged workspace channels"],"must":["Keep per-workspace channel state isolated.","Change subsequent operation context after set_workspace_key."],"mustNot":["Merge channels from multiple workspaces into one listing."],"humanReviewRequired":false},"tags":["workspaces","isolation"],"mock":{"workspaces":[{"name":"Workspace One","key":"rk_live_ws_one","agents":[{"name":"Lead","type":"human"}],"channels":[{"name":"alpha-room","topic":"Alpha","members":["Lead"]}]},{"name":"Workspace Two","key":"rk_live_ws_two","agents":[{"name":"Lead","type":"human"}],"channels":[{"name":"beta-room","topic":"Beta","members":["Lead"]}]}]}} +{"id":"workspaces.invalid-key-format-rejected","suite":"workspaces","executor":"relay","kind":"regression","input":{"message":"A workspace key without the Relay live-key prefix should be rejected.","operation":[{"op":"set_workspace_key","workspaceKey":"not-a-relay-key"}]},"expected":{"ok":false,"errorCode":["invalid_workspace_key"],"toolCallsInclude":["set_workspace_key"],"must":["Reject keys that do not start with the live workspace-key prefix."],"mustNot":["Mutate the active workspace when key validation fails."],"humanReviewRequired":false},"tags":["workspaces","auth","errors"],"mock":{}} +{"id":"workspaces.create-duplicate-name-generates-distinct-key","suite":"workspaces","executor":"relay","kind":"regression","input":{"message":"Creating two workspaces with the same display name should produce distinct workspace records and keys.","operation":[{"op":"create_workspace","name":"Duplicate Display Name","id":"ws_dup_a"},{"op":"register_agent","name":"Lead","type":"human"},{"op":"create_channel","as":"Lead","name":"first-room","topic":"First workspace"},{"op":"create_workspace","name":"Duplicate Display Name","id":"ws_dup_b"},{"op":"register_agent","name":"Lead","type":"human"},{"op":"list_channels","as":"Lead"}]},"expected":{"ok":true,"contentIncludes":["Duplicate Display Name","rk_live_ws_dup_b"],"toolCallsInclude":["create_workspace"],"minToolCalls":6,"must":["Allow display-name reuse by assigning a distinct workspace key.","Start the second workspace with its own empty channel state."],"mustNot":["Reuse the first workspace solely because the display name matches."],"humanReviewRequired":false},"tags":["workspaces","create","isolation"],"mock":{}} diff --git a/evals/suites/workspaces/cases.md b/evals/suites/workspaces/cases.md new file mode 100644 index 000000000..fc6069dfd --- /dev/null +++ b/evals/suites/workspaces/cases.md @@ -0,0 +1,234 @@ +# Workspaces Cases +Workspaces cases pin workspace creation and workspace-key selection behavior for Relay SDK clients using the eval harness. + +## workspaces.create-returns-usable-key +Executor: relay +Kind: capability +Tags: workspaces, create +Human Review: false + +### Message +Create a new Relay workspace and verify the returned workspace key can be used by subsequent operations. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "create_workspace", "name": "Eval Workspace", "id": "ws_eval_create" }, + { "op": "register_agent", "name": "Lead", "type": "human" }, + { "op": "create_channel", "as": "Lead", "name": "created-workspace-room", "topic": "Workspace smoke" }, + { "op": "list_channels", "as": "Lead" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- Eval Workspace +- rk_live_ +- created-workspace-room +toolCallsInclude: +- create_workspace +- register_agent +- create_channel +minToolCalls: 4 + +### Must +- Return a workspace key with the Relay workspace-key prefix. +- Make the created workspace immediately usable by the registered agent. + +### Must Not +- Require a separate set_workspace_key call after create_workspace succeeds. + +## workspaces.set-key-selects-existing-workspace +Executor: relay +Kind: capability +Tags: workspaces, auth +Human Review: false + +### Message +Set the SDK session to an existing workspace key and list that workspace's channels. + +### Mock +```json +{ + "workspaces": [ + { + "name": "Existing Workspace", + "key": "rk_live_existing_eval", + "agents": [ + { "name": "Lead", "type": "human" } + ], + "channels": [ + { "name": "existing-room", "topic": "Existing topic", "members": ["Lead"] } + ] + } + ] +} +``` + +### Operations +```json +[ + { "op": "set_workspace_key", "workspaceKey": "rk_live_existing_eval" }, + { "op": "list_channels", "as": "Lead" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- Existing Workspace +- existing-room +- Existing topic +toolCallsInclude: +- set_workspace_key +- list_channels + +### Must +- Select the seeded workspace by key. +- Resolve subsequent reads against the selected workspace. + +### Must Not +- Leak channels from any other workspace. + +## workspaces.switch-key-isolates-state +Executor: relay +Kind: regression +Tags: workspaces, isolation +Human Review: false + +### Message +Switching workspace keys should isolate channel state between workspaces. + +### Mock +```json +{ + "workspaces": [ + { + "name": "Workspace One", + "key": "rk_live_ws_one", + "agents": [ + { "name": "Lead", "type": "human" } + ], + "channels": [ + { "name": "alpha-room", "topic": "Alpha", "members": ["Lead"] } + ] + }, + { + "name": "Workspace Two", + "key": "rk_live_ws_two", + "agents": [ + { "name": "Lead", "type": "human" } + ], + "channels": [ + { "name": "beta-room", "topic": "Beta", "members": ["Lead"] } + ] + } + ] +} +``` + +### Operations +```json +[ + { "op": "set_workspace_key", "workspaceKey": "rk_live_ws_one" }, + { "op": "list_channels", "as": "Lead" }, + { "op": "set_workspace_key", "workspaceKey": "rk_live_ws_two" }, + { "op": "list_channels", "as": "Lead" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- alpha-room +- beta-room +forbidPhrases: +- merged workspace channels + +### Must +- Keep per-workspace channel state isolated. +- Change subsequent operation context after set_workspace_key. + +### Must Not +- Merge channels from multiple workspaces into one listing. + +## workspaces.invalid-key-format-rejected +Executor: relay +Kind: regression +Tags: workspaces, auth, errors +Human Review: false + +### Message +A workspace key without the Relay live-key prefix should be rejected. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "set_workspace_key", "workspaceKey": "not-a-relay-key" } +] +``` + +### Deterministic Checks +ok: false +errorCode: invalid_workspace_key +toolCallsInclude: +- set_workspace_key + +### Must +- Reject keys that do not start with the live workspace-key prefix. + +### Must Not +- Mutate the active workspace when key validation fails. + +## workspaces.create-duplicate-name-generates-distinct-key +Executor: relay +Kind: regression +Tags: workspaces, create, isolation +Human Review: false + +### Message +Creating two workspaces with the same display name should produce distinct workspace records and keys. + +### Mock +```json +{} +``` + +### Operations +```json +[ + { "op": "create_workspace", "name": "Duplicate Display Name", "id": "ws_dup_a" }, + { "op": "register_agent", "name": "Lead", "type": "human" }, + { "op": "create_channel", "as": "Lead", "name": "first-room", "topic": "First workspace" }, + { "op": "create_workspace", "name": "Duplicate Display Name", "id": "ws_dup_b" }, + { "op": "register_agent", "name": "Lead", "type": "human" }, + { "op": "list_channels", "as": "Lead" } +] +``` + +### Deterministic Checks +ok: true +contentIncludes: +- Duplicate Display Name +- rk_live_ws_dup_b +toolCallsInclude: +- create_workspace +minToolCalls: 6 + +### Must +- Allow display-name reuse by assigning a distinct workspace key. +- Start the second workspace with its own empty channel state. + +### Must Not +- Reuse the first workspace solely because the display name matches. diff --git a/evals/suites/workspaces/rubric.md b/evals/suites/workspaces/rubric.md new file mode 100644 index 000000000..4298ce43b --- /dev/null +++ b/evals/suites/workspaces/rubric.md @@ -0,0 +1,5 @@ +# Workspaces Rubric + +Workspaces cases pass when workspace creation returns a usable Relay-style key, setting a workspace key selects the intended workspace, and workspace state remains isolated across key switches and duplicate display names. + +Failures must be deterministic for invalid key formats, and failed workspace-key selection must not mutate the active workspace context. diff --git a/package-lock.json b/package-lock.json index afcaec1c7..290cd428f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@agent-relay/monorepo", - "version": "8.3.0", + "version": "8.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agent-relay/monorepo", - "version": "8.3.0", + "version": "8.3.1", "license": "Apache-2.0", "workspaces": [ "packages/*", "web" ], "devDependencies": { + "@agent-assistant/telemetry": "^0.4.35", "@testing-library/jest-dom": "^6.9.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.19.3", @@ -35,13 +36,628 @@ "zod": "^3.25.76" }, "engines": { - "node": ">=20.9.0" + "node": ">=20.9.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@agent-assistant/connectivity": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@agent-assistant/connectivity/-/connectivity-0.2.24.tgz", + "integrity": "sha512-Nkrv8xJnQrX+6nzGVqnBGdcit6yqsF0mA4rk2kfX2Kduv14lVyVM9wjpLWLLF165v7LifCg/VlatYBPTEbLxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nanoid": "^5.1.6" + } + }, + "node_modules/@agent-assistant/connectivity/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@agent-assistant/coordination": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@agent-assistant/coordination/-/coordination-0.2.24.tgz", + "integrity": "sha512-InsJwU05TtGxS9iHawFl8EZtzHfesauMaYq6belYrIWF9n/uG7VD9le4jiYnuh+o5/RPQq+Z/ZuK1QKqzad+UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@agent-assistant/connectivity": "^0.2.6", + "nanoid": "^5.1.6" + } + }, + "node_modules/@agent-assistant/coordination/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@agent-assistant/core": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@agent-assistant/core/-/core-0.2.24.tgz", + "integrity": "sha512-lHEkhObn25O3wrDXLR+puQNtulumrwbXuUH2CvdlqfqHnDXtukmJcxnuRw6M9F3antGJOeS4YNcFpB35JlGtlA==", + "dev": true, + "peerDependencies": { + "@agent-assistant/traits": ">=0.1.0" + } + }, + "node_modules/@agent-assistant/harness": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@agent-assistant/harness/-/harness-0.10.4.tgz", + "integrity": "sha512-qCZ5baRh4h+xSVrIc+Uck/N24eyMRHD5A86wmy+QD1uwu03q0bcOMNL9qfzewqrsbmo5MGq8o8St5FNCtwQPrg==", + "dev": true, + "dependencies": { + "@agent-assistant/connectivity": "^0.2.6", + "@agent-assistant/coordination": "^0.2.6", + "@agent-assistant/core": "^0.2.0", + "@agent-assistant/memory": "^0.4.0", + "@agent-assistant/traits": "^0.2.0", + "@agent-assistant/turn-context": "^0.3.4", + "@agent-assistant/vfs": "^0.2.23", + "@agent-relay/sdk": "^4.0.22", + "zod": "^3.25.0" + } + }, + "node_modules/@agent-assistant/harness/node_modules/@agent-relay/config": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-4.0.40.tgz", + "integrity": "sha512-SEXTOTlxkC2kss17YzvAR9bmwMIBclurjI0O2k5xbxxqK/dH3iMM4sJpXXqat1iug95Lrp2Vp/hQJt6xOGeI9g==", + "dev": true, + "dependencies": { + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + } + }, + "node_modules/@agent-assistant/harness/node_modules/@agent-relay/sdk": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@agent-relay/sdk/-/sdk-4.0.40.tgz", + "integrity": "sha512-/65zrEALDUOPU96SBMBl462r6J5w/vQyshR0OV9KnLfzp5eRBhgv9p3beeFDgq6WuLto/A28U5zgnTyST3/n4g==", + "dev": true, + "dependencies": { + "@agent-relay/config": "4.0.40", + "@relaycast/sdk": "^1.1.0", + "@relayfile/sdk": ">=0.1.2 <1", + "@sinclair/typebox": "^0.34.48", + "agent-trajectories": "^0.5.4", + "chalk": "^4.1.2", + "ignore": "^7.0.5", + "listr2": "^10.2.1", + "tar": "^7.5.10", + "ws": "^8.18.3", + "yaml": "^2.7.0" + }, + "peerDependencies": { + "@agent-relay/credential-proxy": "4.0.40", + "@anthropic-ai/claude-agent-sdk": ">=0.1.0", + "@google/adk": ">=0.5.0", + "@langchain/langgraph": ">=1.2.0", + "@mariozechner/pi-coding-agent": ">=0.50.0", + "@openai/agents": ">=0.7.0", + "ai": ">=5.0.0", + "crewai": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@agent-relay/credential-proxy": { + "optional": true + }, + "@anthropic-ai/claude-agent-sdk": { + "optional": true + }, + "@google/adk": { + "optional": true + }, + "@langchain/langgraph": { + "optional": true + }, + "@mariozechner/pi-coding-agent": { + "optional": true + }, + "@openai/agents": { + "optional": true + }, + "ai": { + "optional": true + }, + "crewai": { + "optional": true + } + } + }, + "node_modules/@agent-assistant/harness/node_modules/@relaycast/sdk": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.2.0.tgz", + "integrity": "sha512-/tBN0Up1X+MMQzyyUq9jNSkoTuPtRWcfno3t5iO8PBCJkE9+b89RY+6SxcmII9+8EjlEgMb3xqYey414wDuwTQ==", + "dev": true, + "dependencies": { + "@relaycast/types": "1.2.0", + "zod": "^4.3.6" + } + }, + "node_modules/@agent-assistant/harness/node_modules/@relaycast/sdk/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@agent-assistant/harness/node_modules/@relaycast/types": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@relaycast/types/-/types-1.2.0.tgz", + "integrity": "sha512-ZgnK3VN6RkE2/P+eDRmcr6f4N66yTELT3PHk4ZjIKlmZBL0vgwCZCKC4ZxJrEkcaOPWP4bx3LpajSIKWke6kYA==", + "dev": true, + "dependencies": { + "zod": "^4.3.6" + } + }, + "node_modules/@agent-assistant/harness/node_modules/@relaycast/types/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@agent-assistant/harness/node_modules/agent-trajectories": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.9.tgz", + "integrity": "sha512-t6JhJ5Z+zI+Q/t/egSaAGd1jGewHNTCKiIzoOak7/08sLjxEgFlXCPyvCgfj0HCBkYTpSZddASXFQr8WWliSww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.0.0", + "zod": "^3.23.0" + }, + "bin": { + "trail": "dist/cli/index.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@agent-assistant/memory": { + "version": "0.4.35", + "resolved": "https://registry.npmjs.org/@agent-assistant/memory/-/memory-0.4.35.tgz", + "integrity": "sha512-2mTQIUtzIIaTiahN86HnnQ+7I8H1b7dRbK9sm19xQ/5br/ybDKCCPv3tcxKS6PcFOhPNXQTzJLhzj8tNCV7pqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@agent-relay/memory": "^6.0.9", + "supermemory": "^4.21.1" + } + }, + "node_modules/@agent-assistant/telemetry": { + "version": "0.4.35", + "resolved": "https://registry.npmjs.org/@agent-assistant/telemetry/-/telemetry-0.4.35.tgz", + "integrity": "sha512-2fox8/mANlC3SPW4drj/mOTkPXjICNC4530K9mud8iCO5bK/3VI8opcv2ovGZuzcm0DVIhCV+AiwyK7le/xD6g==", + "dev": true, + "dependencies": { + "@agent-assistant/harness": "^0.10.1" + } + }, + "node_modules/@agent-assistant/traits": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@agent-assistant/traits/-/traits-0.2.24.tgz", + "integrity": "sha512-oopf1b1qO3PS9Yp+XJZFH6r7mSdKMfsdqQBz+pSFI9pcJacL0j1Jo8ECmmzwOP+3kUcudmK9SerLMQIs64Qh0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@agent-assistant/turn-context": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@agent-assistant/turn-context/-/turn-context-0.3.21.tgz", + "integrity": "sha512-QBM/pgl2Z9L95nlnI8P5U3w4ivDG1IhV9UNle+cz0edEDcfITmzTuxqTwSkcjt3ODHCBssHI5XF6dN6f0g2ECQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@agent-assistant/harness": "^0.4.0 || ^0.6.0", + "@agent-assistant/memory": "^0.2.0", + "@agent-assistant/traits": "^0.2.0" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-assistant/harness": { + "version": "0.4.35", + "resolved": "https://registry.npmjs.org/@agent-assistant/harness/-/harness-0.4.35.tgz", + "integrity": "sha512-qbVPRc5LWhz577L/b2TamZBBh8Sej3O/47EaSBp5F0t+XK3NaUj+qKIChu67c11aElm+rwSLiIT7CydPDiUAsQ==", + "dev": true, + "dependencies": { + "@agent-assistant/connectivity": "^0.2.6", + "@agent-assistant/coordination": "^0.2.6", + "@agent-assistant/core": "^0.2.0", + "@agent-assistant/memory": "^0.4.0", + "@agent-assistant/traits": "^0.2.0", + "@agent-assistant/turn-context": "^0.3.4", + "@agent-assistant/vfs": "^0.2.23", + "@agent-relay/sdk": "^6.0.9", + "zod": "^3.25.0" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-assistant/harness/node_modules/@agent-assistant/memory": { + "version": "0.4.35", + "resolved": "https://registry.npmjs.org/@agent-assistant/memory/-/memory-0.4.35.tgz", + "integrity": "sha512-2mTQIUtzIIaTiahN86HnnQ+7I8H1b7dRbK9sm19xQ/5br/ybDKCCPv3tcxKS6PcFOhPNXQTzJLhzj8tNCV7pqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@agent-relay/memory": "^6.0.9", + "supermemory": "^4.21.1" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-assistant/memory": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@agent-assistant/memory/-/memory-0.2.24.tgz", + "integrity": "sha512-Cjhwq5MsBSFPBvP1yebcY4pZf/+qN2ZQbvCgl78+2gi07Ul8AyYyuCfjc72EMnEMrklrSCS0s/Uy3EHFbZJPpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@agent-relay/memory": "^4.0.23" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-assistant/memory/node_modules/@agent-relay/memory": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@agent-relay/memory/-/memory-4.0.40.tgz", + "integrity": "sha512-W/pUIMq4FrxmVqn73mUoEz4mEyBrmrqrkW2uNWf/cxRQZoMki4D9dNCO6QiTVNHUNxFdXhMuS8fxLP8Ht3NEdg==", + "dev": true, + "dependencies": { + "@agent-relay/hooks": "4.0.40" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/broker-darwin-arm64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-arm64/-/broker-darwin-arm64-6.3.6.tgz", + "integrity": "sha512-5JlfwPSRRPK4DRIoePva1EAqd4sqPcdJvMkYHNbQP0B7fV3SpWJo1mi7debOv6yK0s8ZxER47cyLxHsUM0AYgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/broker-darwin-x64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-x64/-/broker-darwin-x64-6.3.6.tgz", + "integrity": "sha512-BxaaTqojtRE5fXuoP4noZz5LVTWL+Wj3PiuBaWMgxoLnkg2Z1uvj97Q+be1jsaQSUgsF8x8JgjzKsfOq1Kc12A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/broker-linux-arm64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-arm64/-/broker-linux-arm64-6.3.6.tgz", + "integrity": "sha512-ZYvz7pa+sDzTgoiIk0pH6AryV6XuB3TODKNqqcKFueyiTAgZ3Rk8AJaN1Xr3X75T7Wdr9629ZOXH26Rqz5Q5KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/broker-linux-x64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-x64/-/broker-linux-x64-6.3.6.tgz", + "integrity": "sha512-rhX95c5uoZDW+yt+1PgPrrfEei8UAnoSxnTlMHAOCx6jWq+1q7VNkH2ZMcp7qcXkQRq5EjSvAk2Y7voXeqlqKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/broker-win32-x64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-win32-x64/-/broker-win32-x64-6.3.6.tgz", + "integrity": "sha512-Tn771YPvB6z5/wmg1S24Q3sTVoWrjPjwJhnBpeePPKTgarHurOzImm/Ak121scQSeKPI2+5SrQfZCBbTR5uNoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/cloud": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/cloud/-/cloud-6.3.6.tgz", + "integrity": "sha512-XcZH1ZUIyiidqE1ZBmJFKIiDPLYkWStJPWtHkLrHmxIgPdQqSACXI+Jh8rwwm/gE7QNG7XJc4HKUOhENVyIX8g==", + "dev": true, + "dependencies": { + "@agent-relay/config": "6.3.6", + "@aws-sdk/client-s3": "3.1020.0", + "ignore": "^7.0.5", + "tar": "^7.5.10" + }, + "optionalDependencies": { + "ssh2": "^1.17.0" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/config": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-6.3.6.tgz", + "integrity": "sha512-kxqH6z2w1ESu7MXQa7LqA5W5pUOU46Vw51NrtvfmKfxgQnrk+bGtHQJjhPMu/hju/msXWwhBdHQ0Qq57SdMWXw==", + "dev": true, + "dependencies": { + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/hooks": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@agent-relay/hooks/-/hooks-4.0.40.tgz", + "integrity": "sha512-WVbmXtJV3dHsKXs7zVOMpjeuEGBBWt0DCkqLuANKQMAaR2FkrhUbK0XZWL3lz2IHDlByM3tu9y4KgzbSoKu5hA==", + "dev": true, + "dependencies": { + "@agent-relay/config": "4.0.40", + "@agent-relay/sdk": "4.0.40", + "@agent-relay/trajectory": "4.0.40" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/hooks/node_modules/@agent-relay/config": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-4.0.40.tgz", + "integrity": "sha512-SEXTOTlxkC2kss17YzvAR9bmwMIBclurjI0O2k5xbxxqK/dH3iMM4sJpXXqat1iug95Lrp2Vp/hQJt6xOGeI9g==", + "dev": true, + "dependencies": { + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/hooks/node_modules/@agent-relay/sdk": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@agent-relay/sdk/-/sdk-4.0.40.tgz", + "integrity": "sha512-/65zrEALDUOPU96SBMBl462r6J5w/vQyshR0OV9KnLfzp5eRBhgv9p3beeFDgq6WuLto/A28U5zgnTyST3/n4g==", + "dev": true, + "dependencies": { + "@agent-relay/config": "4.0.40", + "@relaycast/sdk": "^1.1.0", + "@relayfile/sdk": ">=0.1.2 <1", + "@sinclair/typebox": "^0.34.48", + "agent-trajectories": "^0.5.4", + "chalk": "^4.1.2", + "ignore": "^7.0.5", + "listr2": "^10.2.1", + "tar": "^7.5.10", + "ws": "^8.18.3", + "yaml": "^2.7.0" + }, + "peerDependencies": { + "@agent-relay/credential-proxy": "4.0.40", + "@anthropic-ai/claude-agent-sdk": ">=0.1.0", + "@google/adk": ">=0.5.0", + "@langchain/langgraph": ">=1.2.0", + "@mariozechner/pi-coding-agent": ">=0.50.0", + "@openai/agents": ">=0.7.0", + "ai": ">=5.0.0", + "crewai": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@agent-relay/credential-proxy": { + "optional": true + }, + "@anthropic-ai/claude-agent-sdk": { + "optional": true + }, + "@google/adk": { + "optional": true + }, + "@langchain/langgraph": { + "optional": true + }, + "@mariozechner/pi-coding-agent": { + "optional": true + }, + "@openai/agents": { + "optional": true + }, + "ai": { + "optional": true + }, + "crewai": { + "optional": true + } + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/sdk": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/sdk/-/sdk-6.3.6.tgz", + "integrity": "sha512-0+IB22GS8qkncS0CMDI5sz9V1k3jChjq2F+Ns1fF69a2rWMUHhmcVwR0mNNypnATSjDWWgIXhJwnsjuSm6ulpA==", + "dev": true, + "dependencies": { + "@agent-relay/cloud": "6.3.6", + "@agent-relay/config": "6.3.6", + "@agent-relay/github-primitive": "6.3.6", + "@agent-relay/slack-primitive": "6.3.6", + "@agent-relay/workflow-types": "6.3.6", + "@agentworkforce/harness-kit": "^0.11.0", + "@agentworkforce/workload-router": "^0.11.0", + "@relaycast/sdk": "^1.1.0", + "@relayfile/sdk": ">=0.1.2 <1", + "@sinclair/typebox": "^0.34.48", + "agent-trajectories": "^0.5.4", + "chalk": "^4.1.2", + "ignore": "^7.0.5", + "listr2": "^10.2.1", + "tar": "^7.5.10", + "ws": "^8.18.3", + "yaml": "^2.7.0" + }, + "optionalDependencies": { + "@agent-relay/broker-darwin-arm64": "6.3.6", + "@agent-relay/broker-darwin-x64": "6.3.6", + "@agent-relay/broker-linux-arm64": "6.3.6", + "@agent-relay/broker-linux-x64": "6.3.6", + "@agent-relay/broker-win32-x64": "6.3.6" + }, + "peerDependencies": { + "@agent-relay/credential-proxy": "6.3.6", + "@anthropic-ai/claude-agent-sdk": ">=0.1.0", + "@google/adk": ">=0.5.0", + "@langchain/langgraph": ">=1.2.0", + "@mariozechner/pi-coding-agent": ">=0.50.0", + "@openai/agents": ">=0.7.0", + "ai": ">=5.0.0", + "crewai": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@agent-relay/credential-proxy": { + "optional": true + }, + "@anthropic-ai/claude-agent-sdk": { + "optional": true + }, + "@google/adk": { + "optional": true + }, + "@langchain/langgraph": { + "optional": true + }, + "@mariozechner/pi-coding-agent": { + "optional": true + }, + "@openai/agents": { + "optional": true + }, + "ai": { + "optional": true + }, + "crewai": { + "optional": true + } + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/trajectory": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@agent-relay/trajectory/-/trajectory-4.0.40.tgz", + "integrity": "sha512-+h0aRuT1Gmp6iTXACN+qcdh2ntIbZq6Bk55vTa7GGtyVUldLS5O2XSKDHUWn/gSiThUAlySApr4ZsG9lX1Jbkg==", + "dev": true, + "dependencies": { + "@agent-relay/config": "4.0.40" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@agent-relay/trajectory/node_modules/@agent-relay/config": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-4.0.40.tgz", + "integrity": "sha512-SEXTOTlxkC2kss17YzvAR9bmwMIBclurjI0O2k5xbxxqK/dH3iMM4sJpXXqat1iug95Lrp2Vp/hQJt6xOGeI9g==", + "dev": true, + "dependencies": { + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@relaycast/sdk": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.2.0.tgz", + "integrity": "sha512-/tBN0Up1X+MMQzyyUq9jNSkoTuPtRWcfno3t5iO8PBCJkE9+b89RY+6SxcmII9+8EjlEgMb3xqYey414wDuwTQ==", + "dev": true, + "dependencies": { + "@relaycast/types": "1.2.0", + "zod": "^4.3.6" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@relaycast/sdk/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@relaycast/types": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@relaycast/types/-/types-1.2.0.tgz", + "integrity": "sha512-ZgnK3VN6RkE2/P+eDRmcr6f4N66yTELT3PHk4ZjIKlmZBL0vgwCZCKC4ZxJrEkcaOPWP4bx3LpajSIKWke6kYA==", + "dev": true, + "dependencies": { + "zod": "^4.3.6" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/@relaycast/types/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@agent-assistant/turn-context/node_modules/agent-trajectories": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.9.tgz", + "integrity": "sha512-t6JhJ5Z+zI+Q/t/egSaAGd1jGewHNTCKiIzoOak7/08sLjxEgFlXCPyvCgfj0HCBkYTpSZddASXFQr8WWliSww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.0.0", + "zod": "^3.23.0" + }, + "bin": { + "trail": "dist/cli/index.js" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", - "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "node_modules/@agent-assistant/vfs": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@agent-assistant/vfs/-/vfs-0.2.24.tgz", + "integrity": "sha512-yRT0YMMwskDg5aEvwn6iUDQZxgYqD8AtbIL3dnb+cdHkyd5hoKDi9rQiHq7Mi+yBg/s9ja4cGks9kI1pNLemQA==", "dev": true, "license": "MIT" }, @@ -77,6 +693,15 @@ "resolved": "packages/config", "link": true }, + "node_modules/@agent-relay/github-primitive": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/github-primitive/-/github-primitive-6.3.6.tgz", + "integrity": "sha512-yMGGN5ahqCr+REy1pFRvD0QJm7s+4Qh2UwZ5e8Jg7VpSEzsHtu6zXRwdB+Jwrr3lHmDGtST5ief8cWy23l0wJg==", + "dev": true, + "dependencies": { + "@agent-relay/workflow-types": "6.3.6" + } + }, "node_modules/@agent-relay/harness-driver": { "resolved": "packages/harness-driver", "link": true @@ -85,6 +710,246 @@ "resolved": "packages/harnesses", "link": true }, + "node_modules/@agent-relay/hooks": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/hooks/-/hooks-6.3.6.tgz", + "integrity": "sha512-RkQdbckP6bVRXt+M8e3Dg/6ElCvSios3SR/8Vb2KCAoalPQR2QprJJZ3D68FbDtPC9Ca2KrguZ3hWKa791hDZg==", + "dev": true, + "dependencies": { + "@agent-relay/config": "6.3.6", + "@agent-relay/sdk": "6.3.6", + "@agent-relay/trajectory": "6.3.6" + } + }, + "node_modules/@agent-relay/hooks/node_modules/@agent-relay/broker-darwin-arm64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-arm64/-/broker-darwin-arm64-6.3.6.tgz", + "integrity": "sha512-5JlfwPSRRPK4DRIoePva1EAqd4sqPcdJvMkYHNbQP0B7fV3SpWJo1mi7debOv6yK0s8ZxER47cyLxHsUM0AYgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@agent-relay/hooks/node_modules/@agent-relay/broker-darwin-x64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-x64/-/broker-darwin-x64-6.3.6.tgz", + "integrity": "sha512-BxaaTqojtRE5fXuoP4noZz5LVTWL+Wj3PiuBaWMgxoLnkg2Z1uvj97Q+be1jsaQSUgsF8x8JgjzKsfOq1Kc12A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@agent-relay/hooks/node_modules/@agent-relay/broker-linux-arm64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-arm64/-/broker-linux-arm64-6.3.6.tgz", + "integrity": "sha512-ZYvz7pa+sDzTgoiIk0pH6AryV6XuB3TODKNqqcKFueyiTAgZ3Rk8AJaN1Xr3X75T7Wdr9629ZOXH26Rqz5Q5KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@agent-relay/hooks/node_modules/@agent-relay/broker-linux-x64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-linux-x64/-/broker-linux-x64-6.3.6.tgz", + "integrity": "sha512-rhX95c5uoZDW+yt+1PgPrrfEei8UAnoSxnTlMHAOCx6jWq+1q7VNkH2ZMcp7qcXkQRq5EjSvAk2Y7voXeqlqKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@agent-relay/hooks/node_modules/@agent-relay/broker-win32-x64": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/broker-win32-x64/-/broker-win32-x64-6.3.6.tgz", + "integrity": "sha512-Tn771YPvB6z5/wmg1S24Q3sTVoWrjPjwJhnBpeePPKTgarHurOzImm/Ak121scQSeKPI2+5SrQfZCBbTR5uNoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@agent-relay/hooks/node_modules/@agent-relay/cloud": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/cloud/-/cloud-6.3.6.tgz", + "integrity": "sha512-XcZH1ZUIyiidqE1ZBmJFKIiDPLYkWStJPWtHkLrHmxIgPdQqSACXI+Jh8rwwm/gE7QNG7XJc4HKUOhENVyIX8g==", + "dev": true, + "dependencies": { + "@agent-relay/config": "6.3.6", + "@aws-sdk/client-s3": "3.1020.0", + "ignore": "^7.0.5", + "tar": "^7.5.10" + }, + "optionalDependencies": { + "ssh2": "^1.17.0" + } + }, + "node_modules/@agent-relay/hooks/node_modules/@agent-relay/config": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-6.3.6.tgz", + "integrity": "sha512-kxqH6z2w1ESu7MXQa7LqA5W5pUOU46Vw51NrtvfmKfxgQnrk+bGtHQJjhPMu/hju/msXWwhBdHQ0Qq57SdMWXw==", + "dev": true, + "dependencies": { + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + } + }, + "node_modules/@agent-relay/hooks/node_modules/@agent-relay/sdk": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/sdk/-/sdk-6.3.6.tgz", + "integrity": "sha512-0+IB22GS8qkncS0CMDI5sz9V1k3jChjq2F+Ns1fF69a2rWMUHhmcVwR0mNNypnATSjDWWgIXhJwnsjuSm6ulpA==", + "dev": true, + "dependencies": { + "@agent-relay/cloud": "6.3.6", + "@agent-relay/config": "6.3.6", + "@agent-relay/github-primitive": "6.3.6", + "@agent-relay/slack-primitive": "6.3.6", + "@agent-relay/workflow-types": "6.3.6", + "@agentworkforce/harness-kit": "^0.11.0", + "@agentworkforce/workload-router": "^0.11.0", + "@relaycast/sdk": "^1.1.0", + "@relayfile/sdk": ">=0.1.2 <1", + "@sinclair/typebox": "^0.34.48", + "agent-trajectories": "^0.5.4", + "chalk": "^4.1.2", + "ignore": "^7.0.5", + "listr2": "^10.2.1", + "tar": "^7.5.10", + "ws": "^8.18.3", + "yaml": "^2.7.0" + }, + "optionalDependencies": { + "@agent-relay/broker-darwin-arm64": "6.3.6", + "@agent-relay/broker-darwin-x64": "6.3.6", + "@agent-relay/broker-linux-arm64": "6.3.6", + "@agent-relay/broker-linux-x64": "6.3.6", + "@agent-relay/broker-win32-x64": "6.3.6" + }, + "peerDependencies": { + "@agent-relay/credential-proxy": "6.3.6", + "@anthropic-ai/claude-agent-sdk": ">=0.1.0", + "@google/adk": ">=0.5.0", + "@langchain/langgraph": ">=1.2.0", + "@mariozechner/pi-coding-agent": ">=0.50.0", + "@openai/agents": ">=0.7.0", + "ai": ">=5.0.0", + "crewai": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@agent-relay/credential-proxy": { + "optional": true + }, + "@anthropic-ai/claude-agent-sdk": { + "optional": true + }, + "@google/adk": { + "optional": true + }, + "@langchain/langgraph": { + "optional": true + }, + "@mariozechner/pi-coding-agent": { + "optional": true + }, + "@openai/agents": { + "optional": true + }, + "ai": { + "optional": true + }, + "crewai": { + "optional": true + } + } + }, + "node_modules/@agent-relay/hooks/node_modules/@relaycast/sdk": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.2.0.tgz", + "integrity": "sha512-/tBN0Up1X+MMQzyyUq9jNSkoTuPtRWcfno3t5iO8PBCJkE9+b89RY+6SxcmII9+8EjlEgMb3xqYey414wDuwTQ==", + "dev": true, + "dependencies": { + "@relaycast/types": "1.2.0", + "zod": "^4.3.6" + } + }, + "node_modules/@agent-relay/hooks/node_modules/@relaycast/sdk/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@agent-relay/hooks/node_modules/@relaycast/types": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@relaycast/types/-/types-1.2.0.tgz", + "integrity": "sha512-ZgnK3VN6RkE2/P+eDRmcr6f4N66yTELT3PHk4ZjIKlmZBL0vgwCZCKC4ZxJrEkcaOPWP4bx3LpajSIKWke6kYA==", + "dev": true, + "dependencies": { + "zod": "^4.3.6" + } + }, + "node_modules/@agent-relay/hooks/node_modules/@relaycast/types/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@agent-relay/hooks/node_modules/agent-trajectories": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.9.tgz", + "integrity": "sha512-t6JhJ5Z+zI+Q/t/egSaAGd1jGewHNTCKiIzoOak7/08sLjxEgFlXCPyvCgfj0HCBkYTpSZddASXFQr8WWliSww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.0.0", + "zod": "^3.23.0" + }, + "bin": { + "trail": "dist/cli/index.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@agent-relay/memory": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/memory/-/memory-6.3.6.tgz", + "integrity": "sha512-jetbd2FPOuflN4Ykjw/1QTCNxXTc9s8gzq0iSOmQJF4lpGSRXHfP8sdQCLPNnh0oun17Q7NQmtoRGau3xkr55g==", + "dev": true, + "dependencies": { + "@agent-relay/hooks": "6.3.6" + } + }, "node_modules/@agent-relay/policy": { "resolved": "packages/policy", "link": true @@ -93,14 +958,64 @@ "resolved": "packages/sdk", "link": true }, + "node_modules/@agent-relay/slack-primitive": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/slack-primitive/-/slack-primitive-6.3.6.tgz", + "integrity": "sha512-MAbVJmqCZmGm9D/T1OLNPjsm+APeH8bYE5thn09X9bJugHWhzTCXcJZFx/McsBq/IWlyQliyt6VHw5JbKfkZFA==", + "dev": true, + "dependencies": { + "@agent-relay/workflow-types": "6.3.6", + "@slack/web-api": "^7.15.2" + } + }, "node_modules/@agent-relay/telemetry": { "resolved": "packages/telemetry", "link": true }, + "node_modules/@agent-relay/trajectory": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/trajectory/-/trajectory-6.3.6.tgz", + "integrity": "sha512-37h1o4bhzOl3lQHq0meaC0kXS+gw2m177WICu7a6ttGzGerI31jCUrPlEMWq3P1ieBe0nTbxMjgZw379/6u1fw==", + "dev": true, + "dependencies": { + "@agent-relay/config": "6.3.6" + } + }, + "node_modules/@agent-relay/trajectory/node_modules/@agent-relay/config": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/config/-/config-6.3.6.tgz", + "integrity": "sha512-kxqH6z2w1ESu7MXQa7LqA5W5pUOU46Vw51NrtvfmKfxgQnrk+bGtHQJjhPMu/hju/msXWwhBdHQ0Qq57SdMWXw==", + "dev": true, + "dependencies": { + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.1" + } + }, "node_modules/@agent-relay/utils": { "resolved": "packages/utils", "link": true }, + "node_modules/@agent-relay/workflow-types": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/@agent-relay/workflow-types/-/workflow-types-6.3.6.tgz", + "integrity": "sha512-o3K8yDB03KspX7X39NOr2fvTVb1XSob4MOExUzsnDENiMDLLQKlYz1gjYDejabqGWCbZi4NBbJmVU0kZAHi28w==", + "dev": true + }, + "node_modules/@agentworkforce/harness-kit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@agentworkforce/harness-kit/-/harness-kit-0.11.0.tgz", + "integrity": "sha512-CtW9P0pVm0j5R+kl7OaWMkPz7akYZqJNLmQ8k1m5Ony7NIfxJKuGiTBH9kcg+6vQ7fUtnfkoa34wt3y/pEh2QQ==", + "dev": true, + "dependencies": { + "@agentworkforce/workload-router": "0.11.0" + } + }, + "node_modules/@agentworkforce/workload-router": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@agentworkforce/workload-router/-/workload-router-0.11.0.tgz", + "integrity": "sha512-6Fn4oDsYeNRPe+k7hVfS3Ae3yIocNjuvscVvRswn74CzxSC1X9+1wDhQ5eCvE+S1m1ixAjYGFC9/MNwuhFwjHw==", + "dev": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1611,6 +2526,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -1628,6 +2544,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1645,6 +2562,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1662,6 +2580,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -1679,6 +2598,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1696,6 +2616,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -1713,6 +2634,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1730,6 +2652,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1747,6 +2670,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1764,6 +2688,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1781,6 +2706,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1798,6 +2724,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1815,6 +2742,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1832,6 +2760,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1849,6 +2778,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1866,6 +2796,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1883,6 +2814,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -1900,6 +2832,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1917,6 +2850,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1934,6 +2868,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1951,6 +2886,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -1968,6 +2904,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -1985,6 +2922,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -2002,6 +2940,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2019,6 +2958,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2036,6 +2976,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8499,6 +9440,7 @@ "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, "optional": true, "engines": { "node": ">=10.0.0" @@ -9022,6 +9964,7 @@ "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, "hasInstallScript": true, "optional": true, "dependencies": { @@ -14381,6 +15324,7 @@ "version": "2.27.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -17300,6 +18244,16 @@ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, + "node_modules/supermemory": { + "version": "4.24.12", + "resolved": "https://registry.npmjs.org/supermemory/-/supermemory-4.24.12.tgz", + "integrity": "sha512-xAFextuqk4JuoW33jJaFGqT1oMppN2IgfWUrV18Fv3qAAZ6M1SR1tb+7EBq8vrEQIx4iY2MQh5p+qnfL6lI8Yw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "supermemory": "bin/cli" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -18702,43 +19656,43 @@ }, "packages/brand": { "name": "@agent-relay/brand", - "version": "8.3.0" + "version": "8.3.1" }, "packages/broker-darwin-arm64": { "name": "@agent-relay/broker-darwin-arm64", - "version": "8.3.0", + "version": "8.3.1", "license": "MIT" }, "packages/broker-darwin-x64": { "name": "@agent-relay/broker-darwin-x64", - "version": "8.3.0", + "version": "8.3.1", "license": "MIT" }, "packages/broker-linux-arm64": { "name": "@agent-relay/broker-linux-arm64", - "version": "8.3.0", + "version": "8.3.1", "license": "MIT" }, "packages/broker-linux-x64": { "name": "@agent-relay/broker-linux-x64", - "version": "8.3.0", + "version": "8.3.1", "license": "MIT" }, "packages/broker-win32-x64": { "name": "@agent-relay/broker-win32-x64", - "version": "8.3.0", + "version": "8.3.1", "license": "MIT" }, "packages/cli": { "name": "agent-relay", - "version": "8.3.0", + "version": "8.3.1", "license": "Apache-2.0", "dependencies": { - "@agent-relay/cloud": "8.3.0", - "@agent-relay/config": "8.3.0", - "@agent-relay/harness-driver": "8.3.0", - "@agent-relay/sdk": "8.3.0", - "@agent-relay/utils": "8.3.0", + "@agent-relay/cloud": "8.3.1", + "@agent-relay/config": "8.3.1", + "@agent-relay/harness-driver": "8.3.1", + "@agent-relay/sdk": "8.3.1", + "@agent-relay/utils": "8.3.1", "@modelcontextprotocol/sdk": "^1.0.0", "@relaycast/sdk": "^2.5.1", "@relayflows/cli": "^1.0.1", @@ -18761,9 +19715,9 @@ }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "8.3.0", + "version": "8.3.1", "dependencies": { - "@agent-relay/config": "8.3.0", + "@agent-relay/config": "8.3.1", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -18779,7 +19733,7 @@ }, "packages/config": { "name": "@agent-relay/config", - "version": "8.3.0", + "version": "8.3.1", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -18792,35 +19746,35 @@ }, "packages/harness-driver": { "name": "@agent-relay/harness-driver", - "version": "8.3.0", + "version": "8.3.1", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "8.3.0", + "@agent-relay/sdk": "8.3.1", "ws": "^8.18.3", "zod": "^3.23.8" }, "optionalDependencies": { - "@agent-relay/broker-darwin-arm64": "8.3.0", - "@agent-relay/broker-darwin-x64": "8.3.0", - "@agent-relay/broker-linux-arm64": "8.3.0", - "@agent-relay/broker-linux-x64": "8.3.0", - "@agent-relay/broker-win32-x64": "8.3.0" + "@agent-relay/broker-darwin-arm64": "8.3.1", + "@agent-relay/broker-darwin-x64": "8.3.1", + "@agent-relay/broker-linux-arm64": "8.3.1", + "@agent-relay/broker-linux-x64": "8.3.1", + "@agent-relay/broker-win32-x64": "8.3.1" } }, "packages/harnesses": { "name": "@agent-relay/harnesses", - "version": "8.3.0", + "version": "8.3.1", "license": "Apache-2.0", "dependencies": { - "@agent-relay/harness-driver": "8.3.0", - "@agent-relay/sdk": "8.3.0" + "@agent-relay/harness-driver": "8.3.1", + "@agent-relay/sdk": "8.3.1" } }, "packages/policy": { "name": "@agent-relay/policy", - "version": "8.3.0", + "version": "8.3.1", "dependencies": { - "@agent-relay/config": "8.3.0" + "@agent-relay/config": "8.3.1" }, "devDependencies": { "@types/node": "^22.19.3", @@ -18829,7 +19783,7 @@ }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "8.3.0", + "version": "8.3.1", "dependencies": { "@relaycast/sdk": "^2.5.1" }, @@ -18839,14 +19793,14 @@ }, "packages/telemetry": { "name": "@agent-relay/telemetry", - "version": "8.3.0", + "version": "8.3.1", "deprecated": "@agent-relay/telemetry is deprecated. Telemetry is now internal to the agent-relay CLI." }, "packages/utils": { "name": "@agent-relay/utils", - "version": "8.3.0", + "version": "8.3.1", "dependencies": { - "@agent-relay/config": "8.3.0", + "@agent-relay/config": "8.3.1", "compare-versions": "^6.1.1" }, "devDependencies": { diff --git a/package.json b/package.json index 88782aeb8..9651e2434 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,11 @@ "codegen:models:ts": "node packages/utils/codegen-ts.mjs", "codegen:models:py": "node packages/utils/codegen-py.mjs", "dev:web": "cd web && ../node_modules/.bin/sst dev", - "web": "cd web && npm run dev" + "web": "cd web && npm run dev", + "evals:compile": "node scripts/evals/compile-cases.mjs", + "evals": "npm run evals:compile && node scripts/evals/run-relay-evals.mjs", + "evals:list": "npm run evals:compile && node scripts/evals/run-relay-evals.mjs --list", + "evals:offline": "npm run evals:compile && node scripts/evals/run-relay-evals.mjs --mode offline" }, "author": "AgentWorkforce ", "license": "Apache-2.0", @@ -74,6 +78,7 @@ }, "homepage": "https://github.com/AgentWorkforce/relay#readme", "devDependencies": { + "@agent-assistant/telemetry": "^0.4.35", "@testing-library/jest-dom": "^6.9.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.19.3", diff --git a/scripts/evals/ci-summary.mjs b/scripts/evals/ci-summary.mjs new file mode 100644 index 000000000..c371bad1d --- /dev/null +++ b/scripts/evals/ci-summary.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const RUNS_DIR = path.join(ROOT, '.relay', 'evals', 'runs'); + +const runDir = findLatestRunDir(); +if (!runDir) { + const summary = '# Relay Eval CI Summary\n\nNo Relay eval run found.\n'; + console.log(summary); + if (process.env.GITHUB_STEP_SUMMARY) writeFileSync(process.env.GITHUB_STEP_SUMMARY, summary, { flag: 'a' }); + process.exit(0); +} + +const resultPath = path.join(runDir, 'result.json'); +const result = readResultJson(resultPath); +const failed = result.tests.filter((test) => test.status === 'failed'); +const skipped = result.tests.filter((test) => test.status === 'skipped'); +const needsHuman = result.tests.filter((test) => test.status === 'needs-human'); + +const lines = [ + '# Relay Eval CI Summary', + '', + `- Run directory: \`${path.relative(ROOT, runDir)}\``, + `- Mode: \`${result.mode}\``, + `- Git SHA: \`${result.git_sha}\``, + `- Passed: ${result.passed}`, + `- Needs human review: ${result.needs_human}`, + `- Failed: ${result.failed}`, + `- Skipped: ${result.skipped}`, + '', +]; + +appendStatusSection(lines, 'Failed', failed); +appendStatusSection(lines, 'Skipped', skipped); +appendNeedsHumanSection(lines, needsHuman); + +const summary = `${lines.join('\n')}\n`; +console.log(summary); + +if (process.env.GITHUB_STEP_SUMMARY) writeFileSync(process.env.GITHUB_STEP_SUMMARY, summary, { flag: 'a' }); +const failOnSkipped = + process.env.RELAY_EVAL_FAIL_ON_SKIPPED === '1' || process.env.HUMAN_EVAL_FAIL_ON_SKIPPED === '1'; +if (failed.length > 0 || (failOnSkipped && skipped.length > 0)) process.exitCode = 1; + +function appendStatusSection(lines, title, tests) { + if (tests.length === 0) return; + lines.push(`## ${title}`, ''); + for (const test of tests) { + lines.push(`- \`${test.id}\` (${test.suite}/${test.executor})`); + if (test.error) lines.push(` - ${test.error}`); + for (const check of test.checks ?? []) { + if (check.passed) continue; + lines.push(` - FAIL ${check.name}: ${check.message}`); + } + } + lines.push(''); +} + +function appendNeedsHumanSection(lines, tests) { + lines.push('## Human Review', ''); + if (tests.length === 0) { + lines.push('No cases require human review.', ''); + return; + } + for (const test of tests) lines.push(`- \`${test.id}\` (${test.suite}/${test.executor})`); + lines.push(''); +} + +function findLatestRunDir() { + if (!existsSync(RUNS_DIR)) return null; + const runs = readdirSync(RUNS_DIR) + .map((dir) => path.join(RUNS_DIR, dir)) + .filter((dir) => existsSync(path.join(dir, 'result.json'))) + .flatMap((dir) => { + const result = safeReadResultJson(path.join(dir, 'result.json')); + return result ? [{ dir, result }] : []; + }) + .sort((a, b) => String(b.result.timestamp).localeCompare(String(a.result.timestamp))); + return runs[0]?.dir ?? null; +} + +function readResultJson(filePath) { + const result = safeReadResultJson(filePath); + if (!result) throw new Error(`Could not parse Relay eval result: ${path.relative(ROOT, filePath)}`); + return result; +} + +function safeReadResultJson(filePath) { + try { + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + console.warn( + `Skipping malformed Relay eval result ${path.relative(ROOT, filePath)}: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } +} diff --git a/scripts/evals/compile-cases.mjs b/scripts/evals/compile-cases.mjs new file mode 100644 index 000000000..8898a1081 --- /dev/null +++ b/scripts/evals/compile-cases.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const SUITES_DIR = path.join(ROOT, 'evals', 'suites'); +const GENERATED_HEADER = [ + '# Generated by scripts/evals/compile-cases.mjs from cases.md.', + '# Do not edit this file directly; edit cases.md in this suite instead.', +].join('\n'); + +const args = parseArgs(process.argv.slice(2)); +const suites = args.suite ? [args.suite] : readdirSync(SUITES_DIR).sort(); +let total = 0; + +for (const suite of suites) { + const suiteDir = path.join(SUITES_DIR, suite); + const sourcePath = path.join(suiteDir, 'cases.md'); + const outputPath = path.join(suiteDir, 'cases.jsonl'); + if (!existsSync(sourcePath)) { + if (args.suite) throw new Error(`No cases.md found for suite "${suite}"`); + continue; + } + + const cases = parseCasesMarkdown(readFileSync(sourcePath, 'utf8'), { suite, sourcePath }); + const output = [GENERATED_HEADER, ...cases.map((evalCase) => JSON.stringify(evalCase))].join('\n'); + writeFileSync(outputPath, `${output}\n`); + total += cases.length; + console.log(`${path.relative(ROOT, outputPath)}: wrote ${cases.length} cases`); +} + +console.log(`Compiled ${total} eval cases.`); + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--suite') parsed.suite = readOptionValue(argv, ++index, '--suite'); + else if (arg === '--help' || arg === '-h') { + console.log('Usage: node scripts/evals/compile-cases.mjs [--suite NAME]'); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return parsed; +} + +function readOptionValue(argv, index, option) { + const value = argv[index]; + if (value === undefined || value.startsWith('--')) throw new Error(`${option} requires a value`); + return value; +} + +function parseCasesMarkdown(markdown, context) { + return splitCaseBlocks(markdown).map((block) => parseCaseBlock(block, context)); +} + +function splitCaseBlocks(markdown) { + const lines = markdown.split('\n'); + const blocks = []; + let current = null; + let inFence = false; + for (const line of lines) { + if (/^```/.test(line.trim())) { + inFence = !inFence; + if (current) current.lines.push(line); + continue; + } + if (!inFence) { + const match = /^##\s+([A-Za-z0-9_.:-]+)\s*$/.exec(line.trim()); + if (match) { + if (current) blocks.push(current); + current = { id: match[1], lines: [] }; + continue; + } + } + if (current) current.lines.push(line); + } + if (current) blocks.push(current); + return blocks; +} + +function parseCaseBlock(block, context) { + const sections = splitSections(block.lines); + const meta = parseMetadata(sections.meta); + const deterministic = parseKeyValueSection(sections['Deterministic Checks'] ?? []); + const input = {}; + const message = sectionText(sections.Message); + if (message) input.message = message; + const operation = parseJsonSection(sections.Operation ?? sections.Operations); + if (operation !== undefined) input.operation = operation; + + if (!input.message && !input.operation) { + throw new Error(`${context.sourcePath}: case ${block.id} needs Message or Operation(s)`); + } + + const expected = { ...deterministic }; + const must = parseBullets([...(sections.Must ?? []), ...(sections['Human Must'] ?? [])]); + const mustNot = parseBullets([...(sections['Must Not'] ?? []), ...(sections['Human Must Not'] ?? [])]); + if (must.length > 0) expected.must = must; + if (mustNot.length > 0) expected.mustNot = mustNot; + if (meta.humanReview !== undefined) expected.humanReviewRequired = meta.humanReview; + if (deterministic.humanReviewRequired !== undefined) + expected.humanReviewRequired = deterministic.humanReviewRequired; + + const evalCase = { + id: block.id, + suite: meta.suite ?? context.suite, + executor: meta.executor ?? 'relay', + kind: meta.kind ?? 'capability', + input, + expected, + tags: meta.tags ?? [], + }; + if (meta.trials !== undefined) evalCase.trials = meta.trials; + + const mock = parseJsonSection(sections.Mock); + if (mock !== undefined) evalCase.mock = mock; + return evalCase; +} + +function splitSections(lines) { + const sections = { meta: [] }; + let current = 'meta'; + for (const line of lines) { + const match = /^###\s+(.+?)\s*$/.exec(line.trim()); + if (match) { + current = match[1]; + sections[current] = []; + continue; + } + sections[current].push(line); + } + return sections; +} + +function parseMetadata(lines = []) { + const values = {}; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = /^([A-Za-z][A-Za-z ]*):\s*(.*)$/.exec(trimmed); + if (!match) continue; + const key = normalizeKey(match[1]); + const value = parseScalar(match[2]); + if (key === 'tags') values.tags = splitCommaList(match[2]); + else if (key === 'trials') { + const trials = Number(value); + if (!Number.isInteger(trials) || trials < 1) throw new Error('Trials must be a positive integer'); + values.trials = trials; + } else if (key === 'humanReview') values.humanReview = parseBoolean(value, 'Human Review'); + else values[key] = value; + } + return values; +} + +function parseKeyValueSection(lines = []) { + const values = {}; + let currentKey = null; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const bullet = /^-\s+(.*)$/.exec(trimmed); + if (bullet && currentKey) { + if (!Array.isArray(values[currentKey])) values[currentKey] = []; + values[currentKey].push(parseScalar(bullet[1])); + continue; + } + + const match = /^([A-Za-z][A-Za-z0-9 ]*):\s*(.*)$/.exec(trimmed); + if (!match) continue; + const key = normalizeKey(match[1]); + const rawValue = match[2]; + if (rawValue.trim().length === 0) { + values[key] = []; + currentKey = key; + } else { + values[key] = arrayKeys().has(key) ? splitCommaList(rawValue).map(parseScalar) : parseScalar(rawValue); + currentKey = key; + } + } + return values; +} + +function parseJsonSection(lines = []) { + const text = sectionText(lines); + if (!text) return undefined; + const fence = /^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/i.exec(text); + const raw = fence ? fence[1] : text; + return JSON.parse(raw); +} + +function parseBullets(lines) { + return lines.map((line) => /^-\s+(.*)$/.exec(line.trim())?.[1] ?? '').filter(Boolean); +} + +function sectionText(lines = []) { + return lines.join('\n').trim(); +} + +function splitCommaList(raw) { + return String(raw) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseScalar(raw) { + const value = String(raw).trim(); + if (value === 'true') return true; + if (value === 'false') return false; + if (/^-?\d+$/.test(value)) return Number(value); + if ((value.startsWith('{') && value.endsWith('}')) || (value.startsWith('[') && value.endsWith(']'))) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; +} + +function parseBoolean(value, label) { + if (value === true || value === false) return value; + throw new Error(`${label} must be true or false`); +} + +function normalizeKey(key) { + const words = key.trim().split(/\s+/).filter(Boolean); + return words + .map((word, index) => { + const normalized = word.charAt(0).toUpperCase() + word.slice(1); + return index === 0 ? normalized.charAt(0).toLowerCase() + normalized.slice(1) : normalized; + }) + .join(''); +} + +function arrayKeys() { + return new Set([ + 'contentIncludes', + 'contentMatches', + 'forbidPhrases', + 'forbidPatterns', + 'toolCallsInclude', + 'toolCallsExclude', + 'messageExists', + 'threadReplyCount', + 'reactionCount', + 'channelMembers', + 'agentPresence', + 'errorCode', + 'eventEmitted', + ]); +} diff --git a/scripts/evals/relay-checks.mjs b/scripts/evals/relay-checks.mjs new file mode 100644 index 000000000..0b39d098f --- /dev/null +++ b/scripts/evals/relay-checks.mjs @@ -0,0 +1,147 @@ +export function assertRelayExpected(testCase, actualInput) { + const expected = testCase.expected ?? {}; + const actual = normalizeRelayActual(actualInput); + const checks = []; + + for (const item of asArray(expected.messageExists)) { + const check = objectCheck(item, 'messageExists'); + addCheck( + checks, + `messageExists:${check.text ?? '*'}`, + actual.messages.some((message) => messageMatches(message, check)), + `expected a message matching ${JSON.stringify(check)}` + ); + } + + for (const item of asArray(expected.threadReplyCount)) { + const { parent, count } = objectCheck(item, 'threadReplyCount'); + const replies = actual.messages.filter((message) => message.parentId === parent); + addCheck( + checks, + `threadReplyCount:${parent}`, + replies.length === Number(count), + `expected ${count} replies for ${parent}, got ${replies.length}` + ); + } + + for (const item of asArray(expected.reactionCount)) { + const { messageId, emoji, count } = objectCheck(item, 'reactionCount'); + const message = actual.messages.find( + (candidate) => candidate.id === messageId || candidate.messageId === messageId + ); + const reaction = message?.reactions?.find((candidate) => candidate.emoji === emoji); + const observed = Number(reaction?.count ?? 0); + addCheck( + checks, + `reactionCount:${messageId}:${emoji}`, + observed === Number(count), + `expected ${count} ${emoji} reactions on ${messageId}, got ${observed}` + ); + } + + for (const item of asArray(expected.channelMembers)) { + const { channel, members } = objectCheck(item, 'channelMembers'); + const observed = ( + actual.channels.find((candidate) => candidate.name === normalizeChannelName(channel))?.members ?? [] + ).map((member) => (typeof member === 'string' ? member : (member.agentName ?? member.name))); + const missing = asArray(members).filter((member) => !observed.includes(String(member))); + addCheck( + checks, + `channelMembers:${channel}`, + missing.length === 0, + `expected ${channel} to include members ${missing.join(', ') || '(none missing)'}` + ); + } + + for (const item of asArray(expected.agentPresence)) { + const { name, status } = objectCheck(item, 'agentPresence'); + const agent = actual.agents.find((candidate) => candidate.name === name); + addCheck( + checks, + `agentPresence:${name}`, + Boolean(agent) && (!status || agent.status === status), + `expected agent ${name} presence${status ? ` status ${status}` : ''}` + ); + } + + for (const code of asArray(expected.errorCode)) { + const allowed = asArray(code).map(String); + addCheck( + checks, + `errorCode:${allowed.join('|')}`, + Boolean(actual.error?.code) && allowed.includes(String(actual.error.code)), + `expected error code ${allowed.join(' or ')}, got ${actual.error?.code ?? 'none'}` + ); + } + + for (const item of asArray(expected.eventEmitted)) { + const passed = + typeof item === 'string' + ? actual.events.some((event) => event.type === item) + : actual.events.some((event) => eventMatches(event, objectCheck(item, 'eventEmitted'))); + addCheck( + checks, + `eventEmitted:${typeof item === 'string' ? item : (item.type ?? '*')}`, + passed, + `expected event ${JSON.stringify(item)} to be emitted` + ); + } + + return checks; +} + +function normalizeRelayActual(actualInput) { + const actual = actualInput && typeof actualInput === 'object' ? actualInput : {}; + const observed = actual.observed && typeof actual.observed === 'object' ? actual.observed : {}; + return { + ...actual, + messages: Array.isArray(actual.messages) ? actual.messages : [], + channels: Array.isArray(actual.channels) ? actual.channels : [], + agents: Array.isArray(actual.agents) ? actual.agents : [], + events: Array.isArray(observed.events) + ? observed.events + : Array.isArray(actual.events) + ? actual.events + : [], + error: observed.error ?? actual.error, + }; +} + +function addCheck(checks, name, passed, message) { + checks.push({ name, passed: Boolean(passed), message }); +} + +function asArray(value) { + if (value === undefined || value === null || value === false) return []; + return Array.isArray(value) ? value : [value]; +} + +function objectCheck(value, name) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${name} check must be an object`); + } + return value; +} + +function messageMatches(message, check) { + if (check.channel !== undefined && message.channel?.name !== normalizeChannelName(check.channel)) + return false; + if (check.kind !== undefined && message.kind !== check.kind) return false; + if (check.text !== undefined && !String(message.text ?? '').includes(String(check.text))) return false; + if (check.from !== undefined && message.from?.name !== check.from) return false; + return true; +} + +function eventMatches(event, check) { + return Object.entries(check).every(([key, value]) => deepRead(event, key) === value); +} + +function deepRead(value, key) { + return String(key) + .split('.') + .reduce((current, part) => current?.[part], value); +} + +function normalizeChannelName(name) { + return String(name ?? '').replace(/^#/, ''); +} diff --git a/scripts/evals/relay-executor.mjs b/scripts/evals/relay-executor.mjs new file mode 100644 index 000000000..7f66e58d3 --- /dev/null +++ b/scripts/evals/relay-executor.mjs @@ -0,0 +1,1562 @@ +import { + AgentRelay, + DeliveryRunner, + InMemoryAgentRelayActions, + MINIMAL_AGENT_SESSION_CAPABILITIES, + actionSchemaToJsonSchema, + agentTokenRecoveryMessage, + createListenerHub, + createWorkspaceFacade, + defineHarness, + formatAgentHandle, + isInvalidAgentTokenError, + isInvalidAgentTokenToolResult, + matchesSelector, + nextHarnessName, + normalizeAgentIdentity, + toPublicMessagingEvent, + validateJsonSchemaLite, +} from '@agent-relay/sdk'; + +const FIXED_TIME = '2026-06-09T00:00:00.000Z'; + +export function createRelayExecutor() { + return async function relayExecute(testCase) { + const state = createRelayState(testCase.mock ?? {}); + + try { + for (const operation of normalizeOperations(testCase.input?.operation ?? testCase.input?.operations)) { + try { + await executeOperation(state, operation); + } catch (error) { + state.observed.error = normalizeError(error); + state.contentLines.push( + `error ${operation.op ?? operation.type}: ${state.observed.error.code} ${state.observed.error.message}` + ); + break; + } + } + + return buildActual(state); + } finally { + state.stopDelivery?.(); + } + }; +} + +function createRelayState(mock) { + const state = { + mock, + agents: new Map(), + channels: new Map(), + messages: new Map(), + dmConversations: new Map(), + workspace: { + id: mock.workspace?.id ?? 'ws_eval', + name: mock.workspace?.name ?? 'relay-eval', + key: mock.workspace?.key ?? mock.workspace?.workspaceKey ?? 'rk_live_default', + }, + workspaces: new Map(), + activeWorkspace: undefined, + contentLines: [], + observed: { content: '', events: [], toolCalls: [], error: undefined }, + actions: new InMemoryAgentRelayActions(), + eventBus: createEventBus(), + counters: { agent: 0, channel: 0, message: 0, dm: 0, inbox: 0, invocation: 0 }, + inboxItems: [], + actionInvocations: new Map(), + stopDelivery: undefined, + }; + const defaultWorkspace = createWorkspaceRecord(state, { + id: state.workspace.id, + name: state.workspace.name, + key: state.workspace.key ?? 'rk_live_default', + agents: state.agents, + channels: state.channels, + messages: state.messages, + dmConversations: state.dmConversations, + inboxItems: state.inboxItems, + }); + state.activeWorkspace = defaultWorkspace; + state.workspace = publicWorkspace(defaultWorkspace); + state.workspaces.set(defaultWorkspace.key, defaultWorkspace); + + state.actions.onEvent((event) => emit(state, event.type, event)); + + for (const agent of mock.agents ?? []) { + upsertAgent(state, agent.name, { + type: agent.type ?? 'agent', + status: agent.status ?? 'online', + persona: agent.persona, + metadata: agent.metadata, + token: agent.token, + }); + } + for (const channel of mock.channels ?? []) { + upsertChannel(state, channel.name, { + topic: channel.topic, + metadata: channel.metadata, + archived: channel.archived, + members: channel.members ?? [], + }); + } + for (const message of mock.messages ?? []) { + seedMessage(state, message); + } + if (!state.agents.has('system')) upsertAgent(state, 'system', { type: 'system', status: 'online' }); + for (const workspace of mock.workspaces ?? []) { + seedWorkspace(state, workspace); + } + switchWorkspace(state, defaultWorkspace); + + state.messaging = createMessagingClient(state); + state.relay = new AgentRelay({ + messaging: state.messaging, + actions: state.actions, + createAgentMessaging: (token) => createMessagingClient(state, agentNameFromToken(state, token)), + }); + state.listenerHub = createListenerHub(state.messaging.events, state.actions); + state.workspaceFacade = createWorkspaceFacade(state.messaging, { + buildAgentClient: (registration) => + state.relay.agent({ + id: registration.id, + name: registration.name, + handle: `@${registration.name}`, + token: registration.token, + }), + reconnectAgent: async (apiToken) => { + const name = agentNameFromToken(state, apiToken); + const agent = state.agents.get(name); + if (!agent) throw Object.assign(new Error('Invalid agent token'), { code: 'AGENT_TOKEN_INVALID' }); + return state.relay.agent({ id: agent.id, name: agent.name, handle: `@${agent.name}`, token: apiToken }); + }, + }); + return state; +} + +async function executeOperation(state, operation) { + const op = operation.op ?? operation.type; + if (!op) throw codedError('missing_op', `operation is missing op/type: ${JSON.stringify(operation)}`); + state.observed.toolCalls.push({ name: op, op, as: operation.as }); + + switch (op) { + case 'post_message': + requireAgentClientCapability(state, 'messages.send', operation.as); + return record( + state, + op, + await messagingFor(state, operation.as).messages.send({ + channel: required(operation.channel, 'channel'), + text: operation.text ?? '', + attachments: operation.attachments, + mode: operation.mode, + idempotencyKey: operation.idempotencyKey, + __id: operation.id, + }) + ); + case 'send_dm': + return record( + state, + op, + await messagingFor(state, operation.as).messages.direct({ + to: required(operation.to, 'to'), + text: operation.text ?? '', + attachments: operation.attachments, + mode: operation.mode, + idempotencyKey: operation.idempotencyKey, + __id: operation.id, + }) + ); + case 'send_group_dm': + return record( + state, + op, + await messagingFor(state, operation.as).messages.groupDirect({ + participants: operation.participants ?? [], + name: operation.name, + text: operation.text ?? '', + attachments: operation.attachments, + mode: operation.mode, + idempotencyKey: operation.idempotencyKey, + __id: operation.id, + }) + ); + case 'reply_to_thread': + return record( + state, + op, + await messagingFor(state, operation.as).threads.reply({ + messageId: required(operation.parent ?? operation.messageId, 'parent'), + text: operation.text ?? '', + idempotencyKey: operation.idempotencyKey, + __id: operation.id, + }) + ); + case 'get_thread': + return record( + state, + op, + await state.messaging.threads.get(required(operation.messageId ?? operation.parent, 'messageId')) + ); + case 'add_reaction': + return record( + state, + op, + await messagingFor(state, operation.as).messages.react( + required(operation.messageId, 'messageId'), + required(operation.emoji, 'emoji') + ) + ); + case 'remove_reaction': + return record( + state, + op, + await messagingFor(state, operation.as).messages.unreact( + required(operation.messageId, 'messageId'), + required(operation.emoji, 'emoji') + ) + ); + case 'mark_read': + if (state.mock.deliveryCapabilities?.durableAck === false) { + return record(state, op, { + supported: false, + action: 'markRead', + messageId: required(operation.messageId, 'messageId'), + reason: 'durableAck unsupported', + }); + } + return record( + state, + op, + await messagingFor(state, operation.as).messages.markRead(required(operation.messageId, 'messageId')) + ); + case 'get_readers': + return record( + state, + op, + await state.messaging.messages.readers(required(operation.messageId, 'messageId')) + ); + case 'check_inbox': + return record(state, op, await messagingFor(state, operation.as).inbox.get({ limit: operation.limit })); + case 'list_messages': + return record( + state, + op, + await state.messaging.messages.list(required(operation.channel, 'channel'), { + limit: operation.limit, + }) + ); + case 'search_messages': + return record( + state, + op, + await state.messaging.messages.search(required(operation.query, 'query'), { + channel: operation.channel, + limit: operation.limit, + }) + ); + case 'create_channel': + return record( + state, + op, + await state.messaging.channels.create({ + name: required(operation.name ?? operation.channel, 'name'), + topic: operation.topic, + metadata: operation.metadata, + __id: operation.id, + }) + ); + case 'join_channel': + requireAgentClientCapability(state, 'channels.join', operation.as); + return record( + state, + op, + await messagingFor(state, operation.as).channels.join( + required(operation.channel ?? operation.name, 'channel') + ) + ); + case 'leave_channel': + return record( + state, + op, + await messagingFor(state, operation.as).channels.leave( + required(operation.channel ?? operation.name, 'channel') + ) + ); + case 'invite_to_channel': + return record( + state, + op, + await state.messaging.channels.invite( + required(operation.channel, 'channel'), + required(operation.agent, 'agent') + ) + ); + case 'archive_channel': + return record( + state, + op, + await state.messaging.channels.archive(required(operation.channel ?? operation.name, 'channel')) + ); + case 'set_topic': + return record( + state, + op, + await state.messaging.channels.update(required(operation.channel ?? operation.name, 'channel'), { + topic: operation.topic ?? '', + }) + ); + case 'list_channels': + return record( + state, + op, + await state.messaging.channels.list({ + includeArchived: operation.includeArchived ?? operation.include_archived, + }) + ); + case 'list_dms': + return record(state, op, listDms(state, operation.as)); + case 'create_workspace': { + const workspace = createWorkspaceRecord(state, { + id: operation.id ?? `ws_${state.workspaces.size + 1}`, + name: operation.name ?? 'relay-eval-created', + key: + operation.workspaceKey ?? + operation.key ?? + nextWorkspaceKey(operation.id ?? operation.name ?? state.workspaces.size + 1), + }); + state.workspaces.set(workspace.key, workspace); + switchWorkspace(state, workspace); + if (!state.agents.has('system')) upsertAgent(state, 'system', { type: 'system', status: 'online' }); + return record(state, op, publicWorkspace(workspace)); + } + case 'set_workspace_key': { + const workspaceKey = operation.workspaceKey ?? operation.apiKey ?? operation.key; + if (!String(workspaceKey ?? '').startsWith('rk_live_')) { + throw codedError( + 'invalid_workspace_key', + `workspace key must start with rk_live_: ${workspaceKey ?? ''}` + ); + } + const workspace = state.workspaces.get(workspaceKey); + if (!workspace) throw codedError('workspace_not_found', `workspace not found: ${workspaceKey}`); + switchWorkspace(state, workspace); + return record(state, op, { + ok: true, + workspaceKey: workspace.key, + workspace: publicWorkspace(workspace), + }); + } + case 'register_agent': + return record(state, op, await state.messaging.agents.register(operation.agent ?? operation)); + case 'register_agents': + return record(state, op, await state.workspaceFacade.register(operation.agents ?? [])); + case 'add_agent': { + const name = required(operation.name ?? operation.agent, 'name'); + const agent = upsertAgent(state, name, { + type: 'agent', + status: 'online', + persona: operation.persona, + metadata: operation.metadata, + }); + emit(state, 'agentSpawnRequested', { + type: 'agentSpawnRequested', + agent: { + name, + cli: operation.cli, + task: operation.task, + channel: operation.channel, + alreadyExisted: false, + }, + }); + return record(state, op, publicAgent(agent)); + } + case 'remove_agent': { + const name = required(operation.name ?? operation.agent, 'name'); + const agent = requireAgent(state, name); + if (operation.deleteAgent || operation.delete_agent) state.agents.delete(name); + else agent.status = 'offline'; + emit(state, 'agentReleaseRequested', { + type: 'agentReleaseRequested', + agent: { name }, + reason: operation.reason, + deleted: Boolean(operation.deleteAgent ?? operation.delete_agent), + }); + return record(state, op, { name, removed: true }); + } + case 'list_agents': + return record(state, op, await state.messaging.agents.list({ status: operation.status ?? 'all' })); + case 'deliver': + return record(state, op, await deliver(state, operation)); + case 'register_action': + return record(state, op, registerAction(state, operation)); + case 'invoke_action': + if (operation.mode === 'execute') { + return record( + state, + op, + await state.actions.execute(required(operation.name ?? operation.action, 'name'), operation.input, { + caller: { name: operation.as ?? operation.caller ?? 'sdk', type: 'agent' }, + }) + ); + } + return record( + state, + op, + await state.actions.invoke({ + name: required(operation.name ?? operation.action, 'name'), + input: operation.input, + caller: { name: operation.as ?? operation.caller ?? 'sdk', type: 'agent' }, + context: { emit: (event) => emit(state, event.type, event) }, + }) + ); + case 'define_harness': + return record(state, op, await defineHarnessOperation(state, operation)); + case 'next_harness_name': + return record(state, op, nextHarnessName(required(operation.base, 'base'), operation.explicit)); + case 'normalize_identity': + return record(state, op, normalizeAgentIdentity(operation.input ?? operation)); + case 'format_handle': + return record(state, op, formatAgentHandle(required(operation.name, 'name'))); + case 'read_capabilities': + return record(state, op, MINIMAL_AGENT_SESSION_CAPABILITIES); + case 'resume_session': { + const resumed = { + resumed: true, + sessionId: operation.sessionId ?? operation.id ?? 'session_eval', + agent: operation.agent ?? operation.agentId, + reason: operation.reason, + input: operation.input ?? null, + }; + emit(state, 'session.resumed', { type: 'session.resumed', ...resumed }); + return record(state, op, resumed); + } + case 'add_listener': + requireAgentClientCapability(state, 'events.subscribe', operation.as); + return record(state, op, addListenerOperation(state, operation)); + case 'on_predicate': + return record(state, op, addPredicateListener(state, operation)); + case 'emit_event': + emitRawMessagingEvent(state, operation.raw ?? operation.event ?? operation); + return record(state, op, { emitted: true }); + case 'emit_session_event': + state.listenerHub.emitSessionEvent( + required(operation.agentId, 'agentId'), + required(operation.event, 'event') + ); + emit(state, operation.event.type, { + agentId: operation.agentId, + event: operation.event, + type: operation.event.type, + }); + return record(state, op, { emitted: true }); + case 'match_selector': + return record( + state, + op, + matchesSelector(required(operation.selector, 'selector'), required(operation.type, 'type')) + ); + case 'to_public_event': + return record(state, op, toPublicMessagingEvent(required(operation.raw, 'raw'))); + case 'reconnect': + return record( + state, + op, + await state.workspaceFacade.reconnect({ + apiToken: required(operation.apiToken ?? operation.token, 'apiToken'), + }) + ); + case 'notify': + return record(state, op, await notify(state, operation)); + case 'workspace_info': + return record(state, op, await state.workspaceFacade.info()); + case 'is_invalid_token_error': + return record(state, op, isInvalidAgentTokenError(makeErrorFixture(operation.error ?? operation))); + case 'is_invalid_token_tool_result': + return record(state, op, isInvalidAgentTokenToolResult(operation.result ?? operation)); + case 'token_recovery_message': + return record(state, op, agentTokenRecoveryMessage()); + case 'validate_schema': + return record(state, op, validateJsonSchemaLite(operation.value, required(operation.schema, 'schema'))); + case 'action_schema_to_json_schema': + return record(state, op, actionSchemaToJsonSchema(operation.schema)); + default: + throw codedError('unknown_op', `unknown relay eval operation "${op}"`); + } +} + +function createMessagingClient(state, actorName = 'system') { + const actor = () => { + const agent = requireAgent(state, actorName); + if (agent.status === 'offline') throw codedError('agent_offline', `agent is offline: ${actorName}`); + return agent; + }; + return { + capabilities: { + serverDeliveryState: + state.mock.delivery?.serverDeliveryState !== false && state.mock.serverDeliveryState !== false, + durableDelivery: false, + durableAck: state.mock.deliveryCapabilities?.durableAck !== false, + durableFail: false, + durableDefer: false, + }, + agents: { + list: async (options = {}) => + [...state.agents.values()] + .filter((agent) => options.status === 'all' || !options.status || agent.status === options.status) + .map(publicAgent), + get: async (name) => publicAgent(requireAgent(state, name)), + register: async (input) => { + const cleanName = normalizeAgentName(required(input.name, 'name')); + if (state.agents.has(cleanName)) + throw codedError('agent_exists', `agent already exists: ${cleanName}`); + const agent = upsertAgent(state, required(input.name, 'name'), input); + agent.status = input.status ?? 'online'; + emit(state, 'agentOnline', { type: 'agentOnline', agent: { name: agent.name } }); + return { + id: agent.id, + name: agent.name, + token: agent.token, + status: agent.status, + createdAt: agent.createdAt, + }; + }, + me: async () => publicAgent(actor()), + update: async (name, input) => publicAgent(upsertAgent(state, name, input)), + delete: async (name) => { + state.agents.delete(name); + }, + presence: async () => + [...state.agents.values()].map((agent) => ({ + agentId: agent.id, + agentName: agent.name, + status: agent.status === 'offline' ? 'offline' : 'online', + })), + }, + channels: { + list: async (options = {}) => + [...state.channels.values()] + .filter((channel) => options.includeArchived || !channel.archived) + .map(publicChannel), + get: async (name) => publicChannel(requireChannel(state, name)), + create: async (input) => { + const cleanName = normalizeChannelName(required(input.name, 'name')); + if (state.channels.has(cleanName)) + throw codedError('channel_exists', `channel already exists: ${cleanName}`); + const channel = upsertChannel(state, input.name, { + id: input.__id, + topic: input.topic, + metadata: input.metadata, + }); + emit(state, 'channelCreated', { + type: 'channelCreated', + channel: { name: channel.name, topic: channel.topic }, + }); + return publicChannel(channel); + }, + update: async (name, input) => { + const channel = requireChannel(state, name); + if ('topic' in input) channel.topic = input.topic ?? undefined; + if (input.metadata) channel.metadata = input.metadata; + emit(state, 'channelUpdated', { + type: 'channelUpdated', + channel: { name: channel.name, topic: channel.topic }, + }); + return publicChannel(channel); + }, + archive: async (name) => { + const channel = requireChannel(state, name); + channel.archived = true; + emit(state, 'channelArchived', { type: 'channelArchived', channel: { name: channel.name } }); + }, + join: async (name) => { + const channel = requireChannel(state, name); + if (channel.archived) throw codedError('channel_archived', `channel is archived: ${channel.name}`); + channel.members.add(actor().name); + emit(state, 'memberJoined', { type: 'memberJoined', channel: channel.name, agentName: actor().name }); + }, + leave: async (name) => { + const channel = requireChannel(state, name); + if (channel.archived) throw codedError('channel_archived', `channel is archived: ${channel.name}`); + channel.members.delete(actor().name); + emit(state, 'memberLeft', { type: 'memberLeft', channel: channel.name, agentName: actor().name }); + }, + invite: async (channelName, agentName) => { + const channel = requireChannel(state, channelName); + if (channel.archived) throw codedError('channel_archived', `channel is archived: ${channel.name}`); + requireAgent(state, agentName); + channel.members.add(agentName); + emit(state, 'memberJoined', { type: 'memberJoined', channel: channel.name, agentName }); + }, + members: async (name) => + [...requireChannel(state, name).members].map((member) => ({ + agentId: requireAgent(state, member).id, + agentName: member, + role: 'member', + muted: false, + })), + mute: async (name) => + emit(state, 'channelMuted', { + type: 'channelMuted', + channel: normalizeChannelName(name), + agentName: actor().name, + }), + unmute: async (name) => + emit(state, 'channelUnmuted', { + type: 'channelUnmuted', + channel: normalizeChannelName(name), + agentName: actor().name, + }), + }, + messages: createMessagesSurface(state, actorName), + threads: { + get: async (messageId) => { + const parent = requireMessage(state, messageId); + return { + parent, + replies: [...state.messages.values()].filter((message) => message.parentId === parent.id), + }; + }, + reply: async (input) => createReply(state, actorName, input), + }, + inbox: createInboxSurface(state, actorName), + events: state.eventBus, + deliveries: { + ack: async (messageId) => ({ supported: false, action: 'ack', messageId }), + fail: async (messageId, reason) => ({ supported: false, action: 'fail', messageId, reason }), + defer: async (messageId, deferUntil) => ({ supported: false, action: 'defer', messageId, deferUntil }), + }, + integrations: emptyIntegrations(), + webhooks: emptyWebhooks(), + commands: createCommandsSurface(state), + workspace: { info: async () => state.workspace }, + }; +} + +function createMessagesSurface(state, actorName) { + return { + send: async (input) => { + const channel = upsertChannel(state, input.channel, { members: [actorName] }); + const message = createMessage(state, { + id: input.__id, + kind: 'channel', + from: actorName, + text: input.text, + channel: channel.name, + attachments: input.attachments, + mode: input.mode, + }); + emit(state, 'messageCreated', { type: 'messageCreated', channel: channel.name, message }); + return message; + }, + list: async (channel, options = {}) => + limit( + [...state.messages.values()].filter( + (message) => message.channel?.name === normalizeChannelName(channel) && !message.parentId + ), + options.limit + ), + get: async (id) => requireMessage(state, id), + reply: async (input) => createReply(state, actorName, input), + direct: async (input) => { + upsertAgent(state, input.to, { status: 'online' }); + const conversationId = dmIdFor([actorName, input.to]); + const message = createMessage(state, { + id: input.__id, + kind: 'dm', + from: actorName, + text: input.text, + conversationId, + attachments: input.attachments, + mode: input.mode, + target: { kind: 'agent', agentName: input.to }, + }); + upsertDm(state, conversationId, [actorName, input.to]); + emit(state, 'dmReceived', { type: 'dmReceived', conversationId, message }); + return message; + }, + groupDirect: async (input) => { + const participants = [...new Set([actorName, ...(input.participants ?? [])])]; + participants.forEach((name) => upsertAgent(state, name, { status: 'online' })); + const conversationId = input.conversationId ?? `gdm_${++state.counters.dm}`; + upsertDm(state, conversationId, participants, input.name); + const message = createMessage(state, { + id: input.__id, + kind: 'group_dm', + from: actorName, + text: input.text, + conversationId, + attachments: input.attachments, + mode: input.mode, + target: { kind: 'group_dm', conversationId }, + }); + emit(state, 'groupDmReceived', { type: 'groupDmReceived', conversationId, message }); + return message; + }, + createGroupDirect: async (input) => + upsertDm(state, `gdm_${++state.counters.dm}`, input.participants ?? [], input.name), + listDirect: async (input) => + limit( + [...state.messages.values()].filter((message) => message.conversationId === input.conversationId), + input.limit + ), + markRead: async (messageId) => { + const message = requireMessage(state, messageId); + message.readers ??= new Set(); + message.readers.add(actorName); + const receipt = { + messageId: message.id, + agentId: requireAgent(state, actorName).id, + agentName: actorName, + readAt: FIXED_TIME, + }; + emit(state, 'messageRead', { + type: 'messageRead', + messageId: message.id, + agentName: actorName, + readAt: receipt.readAt, + }); + return receipt; + }, + readers: async (messageId) => + [...(requireMessage(state, messageId).readers ?? new Set())].map((name) => ({ + messageId, + agentId: requireAgent(state, name).id, + agentName: name, + readAt: FIXED_TIME, + })), + readStatus: async (channel) => + [...state.agents.values()].map((agent) => ({ + agentName: agent.name, + lastReadId: lastMessageInChannel(state, channel)?.id, + lastReadAt: FIXED_TIME, + })), + reactions: async (messageId) => requireMessage(state, messageId).reactions ?? [], + react: async (messageId, emoji) => { + const message = requireMessage(state, messageId); + const reaction = ensureReaction(message, emoji); + if (!reaction.agents.includes(actorName)) reaction.agents.push(actorName); + reaction.count = reaction.agents.length; + emit(state, 'reactionAdded', { + type: 'reactionAdded', + messageId: message.id, + emoji, + agentName: actorName, + }); + return reaction; + }, + unreact: async (messageId, emoji) => { + const message = requireMessage(state, messageId); + const reaction = ensureReaction(message, emoji); + reaction.agents = reaction.agents.filter((name) => name !== actorName); + reaction.count = reaction.agents.length; + emit(state, 'reactionRemoved', { + type: 'reactionRemoved', + messageId: message.id, + emoji, + agentName: actorName, + }); + }, + search: async (query, options = {}) => + limit( + [...state.messages.values()] + .filter( + (message) => + (!options.channel || message.channel?.name === normalizeChannelName(options.channel)) && + String(message.text).toLowerCase().includes(String(query).toLowerCase()) + ) + .map((message) => ({ + id: message.id, + channelName: message.channel?.name ?? '', + agentName: message.from?.name ?? '', + text: message.text, + createdAt: message.createdAt, + relevanceScore: 1, + })), + options.limit + ), + }; +} + +function createInboxSurface(state, actorName) { + return { + get: async () => ({ + unreadChannels: [], + mentions: [...state.messages.values()].filter((message) => + String(message.text).includes(`@${actorName}`) + ), + unreadDms: [...state.messages.values()] + .filter( + (message) => + message.kind === 'dm' && + message.target?.agentName === actorName && + !message.readers?.has(actorName) + ) + .map((message) => ({ + conversationId: message.conversationId, + from: message.from?.name, + unreadCount: 1, + lastMessage: { id: message.id, text: message.text, createdAt: message.createdAt }, + })), + recentReactions: state.observed.events + .filter((event) => event.type === 'reactionAdded') + .map((event) => ({ + messageId: event.messageId, + channelName: requireMessage(state, event.messageId).channel?.name ?? '', + emoji: event.emoji, + agentName: event.agentName, + createdAt: FIXED_TIME, + })), + }), + list: async () => ({ + items: state.inboxItems.filter((item) => !actorName || item.recipient.name === actorName), + }), + subscribe: async function* () { + for (const item of state.inboxItems.filter( + (candidate) => candidate.recipient.name === actorName || !actorName + )) + yield item; + }, + ack: async (input) => updateInboxItem(state, input.inboxItemId, input.state ?? 'delivered'), + fail: async (input) => updateInboxItem(state, input.inboxItemId, 'failed', input.error), + defer: async (input) => + updateInboxItem(state, input.inboxItemId, 'deferred', input.reason, input.availableAt), + markRead: async (input) => updateInboxItem(state, input.inboxItemId, 'read'), + }; +} + +function createCommandsSurface(state) { + return { + register: async (input) => ({ + command: input.command, + description: input.description, + handlerAgent: input.handlerAgent, + inputSchema: input.inputSchema, + outputSchema: input.outputSchema, + availableTo: input.availableTo, + }), + list: async () => state.actions.list(), + delete: async (command) => { + state.actions.unregister(command); + }, + available: () => true, + agentScoped: () => true, + invoke: async (name, input = {}) => { + const invocationId = `inv_${++state.counters.invocation}`; + state.actionInvocations.set(invocationId, { + invocationId, + actionName: name, + callerName: 'sdk', + input, + status: 'invoked', + }); + emit(state, 'actionInvoked', { + type: 'actionInvoked', + invocationId, + actionName: name, + callerName: 'sdk', + handlerAgentId: 'handler', + }); + return { invocationId, actionName: name, input, status: 'invoked' }; + }, + getInvocation: async (_name, invocationId) => state.actionInvocations.get(invocationId), + completeInvocation: async (name, invocationId, data) => { + const invocation = state.actionInvocations.get(invocationId) ?? { invocationId, actionName: name }; + Object.assign(invocation, data, { status: data.error ? 'failed' : 'completed' }); + state.actionInvocations.set(invocationId, invocation); + return invocation; + }, + }; +} + +async function deliver(state, operation) { + if ( + state.mock.delivery?.serverDeliveryState === false || + state.mock.serverDeliveryState === false || + operation.serverDeliveryState === false + ) { + state.messaging.capabilities.serverDeliveryState = false; + } + const recipient = operation.to ?? operation.as ?? 'worker'; + upsertAgent(state, recipient, { status: 'online' }); + const message = createMessage(state, { + id: operation.id, + kind: 'dm', + from: operation.from ?? 'system', + text: operation.text ?? 'delivery', + conversationId: dmIdFor(['system', recipient]), + target: { kind: 'agent', agentName: recipient }, + }); + const item = { + id: `inbox_${++state.counters.inbox}`, + recipient: { name: recipient }, + state: 'queued', + attempts: 0, + message, + }; + state.inboxItems.push(item); + const results = []; + const fixture = state.mock.delivery ?? {}; + const deliveryResult = operation.result ?? fixture.result; + const throws = operation.throws ?? fixture.throws; + const delivery = deliveryResult + ? { inject: async () => deliveryResult } + : throws + ? { + inject: async () => { + throw new Error(String(throws)); + }, + } + : { + receiveMessage: async (_message, context) => ({ + status: operation.status ?? 'delivered', + deliveryId: context.id, + metadata: { mode: context.mode }, + }), + }; + const runner = new DeliveryRunner({ + messaging: messagingFor(state, recipient), + delivery, + agentName: recipient, + context: { + mode: + operation.mode === 'steer' + ? 'immediate' + : operation.mode === 'wait' + ? 'next-message' + : operation.mode, + }, + onResult: (_item, result) => results.push(result), + }); + state.stopDelivery = () => runner.stop(); + await runner.start(); + return { item, results }; +} + +function registerAction(state, operation) { + const name = required(operation.name ?? operation.action, 'name'); + const fixture = operation.handlerFixture ?? operation.fixture ?? 'echo_text'; + const inputSchema = operation.inputSchemaFixture + ? schemaFixture(operation.inputSchemaFixture) + : (operation.inputSchema ?? operation.input); + const policy = + fixture === 'policy_deny' ? () => ({ allowed: false, reason: 'policy denied by fixture' }) : undefined; + const handle = state.actions.register({ + name, + description: operation.description ?? name, + inputSchema, + outputSchema: operation.outputSchema ?? operation.output, + policy, + handler: handlerFixture(fixture), + }); + const descriptor = state.actions.get(name); + if (operation.unregisterAfter) handle.unregister(); + return { + registered: name, + handlerFixture: fixture, + descriptor, + unregistered: Boolean(operation.unregisterAfter), + }; +} + +function handlerFixture(name) { + switch (name) { + case 'echo_text': + return (input) => ({ echoed: input?.text ?? input?.message ?? '' }); + case 'sum_numbers': + return (input) => ({ + sum: + input?.count !== undefined + ? Number(input.count) * 2 + : Number(input?.a ?? 0) + Number(input?.b ?? 0), + }); + case 'throw_error': + return () => { + throw new Error('fixture threw'); + }; + case 'invalid_output': + return () => ({ invalid: 'output' }); + case 'policy_deny': + return (input) => input; + default: + throw codedError('unknown_handler_fixture', `unknown handlerFixture: ${name}`); + } +} + +function schemaFixture(name) { + switch (name) { + case 'coerce_string_count': + return { + safeParse(input) { + if (input && typeof input === 'object' && typeof input.count === 'string') { + return { success: true, data: { count: Number(input.count) } }; + } + if ( + input && + typeof input === 'object' && + typeof input.a === 'string' && + typeof input.b === 'string' + ) { + return { success: true, data: { a: Number(input.a), b: Number(input.b) } }; + } + return { + success: false, + error: { issues: [{ path: ['count'], message: 'expected string count or string a/b values' }] }, + }; + }, + }; + default: + throw codedError('unknown_schema_fixture', `unknown inputSchemaFixture: ${name}`); + } +} + +async function defineHarnessOperation(state, operation) { + const harness = defineHarness({ + name: required(operation.name, 'name'), + version: operation.version, + create: async (input, context) => ({ + identity: normalizeAgentIdentity({ + id: context.agent.id, + name: input?.name ?? operation.name, + handle: context.agent.handle, + }), + capabilities: MINIMAL_AGENT_SESSION_CAPABILITIES, + receiveMessage: async (_message, deliveryContext) => ({ + status: 'delivered', + deliveryId: deliveryContext.id, + }), + release: async () => {}, + }), + }); + const agent = await harness.create(operation.input ?? {}); + return { harness: harness.config.name, agent }; +} + +function addListenerOperation(state, operation) { + const events = []; + const off = state.listenerHub.addListener(operation.selector ?? '*', (event) => { + events.push(event); + state.contentLines.push(`listener ${event.type}`); + }); + return { listening: operation.selector ?? '*', unsubscribe: Boolean(off), events }; +} + +function addPredicateListener(state, operation) { + const predicate = buildPredicate(state, operation); + const off = state.listenerHub.addListener(predicate, (event) => { + state.contentLines.push(`predicate ${event.type}`); + }); + return { listening: true, unsubscribe: Boolean(off) }; +} + +function buildPredicate(state, operation) { + if (operation.predicate === 'message.created') { + let predicate = state.listenerHub.events.message.created(); + if (operation.channel) predicate = predicate.in(operation.channel); + if (operation.mentions) predicate = predicate.mentions(operation.mentions); + return predicate; + } + if (operation.predicate === 'message.read') return state.listenerHub.events.message.read(); + if (operation.predicate === 'message.reacted') return state.listenerHub.events.message.reacted(); + if (operation.predicate === 'action') { + let predicate = state.listenerHub.action(required(operation.action, 'action')); + if (operation.calledBy) predicate = predicate.calledBy(operation.calledBy); + if (operation.phase === 'completed') predicate = predicate.completed(); + if (operation.phase === 'failed') predicate = predicate.failed(); + if (operation.phase === 'denied') predicate = predicate.denied(); + return predicate; + } + if (operation.predicate === 'status') + return state.listenerHub + .agent({ id: required(operation.agentId, 'agentId'), name: operation.name ?? operation.agentId }) + .status.becomes(operation.status ?? 'idle'); + throw codedError('unknown_predicate', `unknown predicate: ${operation.predicate}`); +} + +function emitRawMessagingEvent(state, raw) { + state.eventBus.emit(raw.type, raw); + state.eventBus.emit('any', raw); + state.observed.events.push(raw); +} + +async function notify(state, operation) { + const target = required(operation.target, 'target'); + const text = operation.options?.text ?? operation.text ?? 'notification'; + return messagingFor(state, operation.as).messages.direct({ to: target, text }); +} + +function record(state, op, value) { + const text = stableStringify(value); + state.contentLines.push(`${op}: ${text}`); + state.observed.content = state.contentLines.join('\n'); + return value; +} + +function buildActual(state) { + const messages = [...state.messages.values()].map(publicMessage); + const channels = [...state.channels.values()].map((channel) => ({ + ...publicChannel(channel), + members: [...channel.members].sort(compareStrings), + })); + const agents = [...state.agents.values()].map(publicAgent); + state.observed.content = state.contentLines.join('\n'); + return { + ok: state.observed.error === undefined, + status: state.observed.error ? 'failed' : 'completed', + content: state.observed.content, + toolCalls: state.observed.toolCalls, + observed: state.observed, + messages, + channels, + agents, + inboxItems: state.inboxItems, + workspace: state.workspace, + notes: 'Relay eval ran against an in-memory SDK harness with no live broker.', + }; +} + +function normalizeOperations(input) { + if (input === undefined) return []; + return Array.isArray(input) ? input : [input]; +} + +function messagingFor(state, as) { + return as ? createMessagingClient(state, as) : state.messaging; +} + +function requireAgentClientCapability(state, capability, as) { + if (as && state.mock.clientCapabilities?.agentClient === false) { + throw codedError( + 'relay_capability_error', + `RelayCapabilityError: ${capability} requires an agent-scoped client` + ); + } +} + +function createWorkspaceRecord(state, input = {}) { + return { + id: input.id ?? `ws_${state.workspaces.size + 1}`, + name: input.name ?? 'Relay Eval Workspace', + key: input.key ?? nextWorkspaceKey(input.id ?? input.name ?? state.workspaces.size + 1), + agents: input.agents ?? new Map(), + channels: input.channels ?? new Map(), + messages: input.messages ?? new Map(), + dmConversations: input.dmConversations ?? new Map(), + inboxItems: input.inboxItems ?? [], + }; +} + +function seedWorkspace(state, input) { + const workspace = createWorkspaceRecord(state, { + id: input.id, + name: input.name, + key: input.key ?? input.workspaceKey, + }); + state.workspaces.set(workspace.key, workspace); + const previous = state.activeWorkspace; + switchWorkspace(state, workspace); + for (const agent of input.agents ?? []) { + upsertAgent(state, agent.name, { + type: agent.type ?? 'agent', + status: agent.status ?? 'online', + persona: agent.persona, + metadata: agent.metadata, + token: agent.token, + }); + } + for (const channel of input.channels ?? []) { + upsertChannel(state, channel.name, { + topic: channel.topic, + metadata: channel.metadata, + archived: channel.archived, + members: channel.members ?? [], + }); + } + for (const message of input.messages ?? []) seedMessage(state, message); + if (!state.agents.has('system')) upsertAgent(state, 'system', { type: 'system', status: 'online' }); + if (previous) switchWorkspace(state, previous); + return workspace; +} + +function switchWorkspace(state, workspace) { + state.activeWorkspace = workspace; + state.workspace = publicWorkspace(workspace); + state.agents = workspace.agents; + state.channels = workspace.channels; + state.messages = workspace.messages; + state.dmConversations = workspace.dmConversations; + state.inboxItems = workspace.inboxItems; +} + +function publicWorkspace(workspace) { + return { id: workspace.id, name: workspace.name, key: workspace.key, workspaceKey: workspace.key }; +} + +function nextWorkspaceKey(seed) { + return `rk_live_${sanitizeKey(seed)}`; +} + +function sanitizeKey(value) { + return ( + String(value ?? 'workspace') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase() || 'workspace' + ); +} + +function seedMessage(state, message) { + const from = message.from ?? message.agentName ?? message.as ?? 'system'; + const to = message.to ? normalizeAgentName(message.to) : undefined; + upsertAgent(state, from, { type: 'agent', status: 'online' }); + if (to) upsertAgent(state, to, { type: 'agent', status: 'online' }); + if (message.channel) upsertChannel(state, message.channel, { members: [from] }); + const kind = message.kind ?? (message.threadParent ? 'thread_reply' : message.channel ? 'channel' : 'dm'); + const conversationId = message.conversationId ?? (kind === 'dm' && to ? dmIdFor([from, to]) : undefined); + createMessage(state, { + id: message.id, + kind, + from, + text: message.text ?? '', + channel: message.channel, + parentId: message.threadParent ?? message.parentId, + conversationId, + attachments: message.attachments, + target: to ? { kind: 'agent', agentName: to } : undefined, + }); + if (conversationId && to) upsertDm(state, conversationId, [from, to]); +} + +function listDms(state, actorName) { + const conversations = [...state.dmConversations.values()]; + const visible = actorName + ? conversations.filter((conversation) => + conversation.participants.includes(normalizeAgentName(actorName)) + ) + : conversations; + return visible.map((conversation) => ({ + ...conversation, + messages: [...state.messages.values()] + .filter((message) => message.conversationId === conversation.id) + .map(publicMessage), + })); +} + +function upsertAgent(state, name, input = {}) { + const cleanName = normalizeAgentName(required(name, 'agent.name')); + const existing = state.agents.get(cleanName); + const agent = existing ?? { + id: input.id ?? `agent_${++state.counters.agent}`, + name: cleanName, + type: input.type ?? 'agent', + status: input.status ?? 'online', + token: input.token ?? `at_eval_${cleanName}`, + metadata: {}, + channels: new Set(), + createdAt: FIXED_TIME, + }; + Object.assign(agent, { + type: input.type ?? agent.type, + status: input.status ?? agent.status, + persona: input.persona ?? agent.persona, + metadata: input.metadata ?? agent.metadata, + }); + state.agents.set(cleanName, agent); + return agent; +} + +function upsertChannel(state, name, input = {}) { + const cleanName = normalizeChannelName(required(name, 'channel.name')); + const existing = state.channels.get(cleanName); + const channel = existing ?? { + id: input.id ?? `ch_${++state.counters.channel}`, + name: cleanName, + topic: input.topic, + metadata: input.metadata ?? {}, + archived: Boolean(input.archived), + members: new Set(), + createdAt: FIXED_TIME, + }; + if (input.topic !== undefined) channel.topic = input.topic; + if (input.metadata !== undefined) channel.metadata = input.metadata; + if (input.archived !== undefined) channel.archived = Boolean(input.archived); + for (const member of input.members ?? []) { + channel.members.add(normalizeAgentName(member)); + upsertAgent(state, member, { status: 'online' }); + } + state.channels.set(cleanName, channel); + return channel; +} + +function createMessage(state, input) { + const id = input.id ?? `m_${++state.counters.message}`; + const from = upsertAgent(state, input.from ?? 'system', { status: 'online' }); + const message = { + id, + messageId: id, + kind: input.kind, + text: input.text ?? '', + from: { id: from.id, name: from.name }, + channel: input.channel ? { name: normalizeChannelName(input.channel) } : undefined, + target: input.target, + conversationId: input.conversationId, + parentId: input.parentId, + threadId: input.parentId, + mode: input.mode, + attachments: input.attachments ?? [], + reactions: [], + readers: new Set(), + replyCount: 0, + createdAt: FIXED_TIME, + }; + if (input.parentId && state.messages.has(input.parentId)) { + state.messages.get(input.parentId).replyCount += 1; + } + state.messages.set(id, message); + return message; +} + +function createReply(state, actorName, input) { + const parent = requireMessage(state, input.messageId); + const message = createMessage(state, { + id: input.__id, + kind: 'thread_reply', + from: actorName, + text: input.text, + channel: parent.channel?.name, + parentId: parent.id, + }); + emit(state, 'threadReply', { + type: 'threadReply', + channel: parent.channel?.name ?? '', + parentId: parent.id, + message, + }); + return message; +} + +function upsertDm(state, id, participants, name) { + const conversation = { + id, + conversationId: id, + name, + participants: [...new Set(participants)].sort(compareStrings), + }; + state.dmConversations.set(id, conversation); + return conversation; +} + +function emit(state, type, event) { + const raw = event?.type ? event : { ...event, type }; + state.observed.events.push(raw); + state.eventBus.emit(type, raw); + state.eventBus.emit('any', raw); +} + +function createEventBus() { + const handlers = new Map(); + return { + connect() {}, + async disconnect() {}, + subscribe() {}, + unsubscribe() {}, + on(type, handler) { + const set = handlers.get(type) ?? new Set(); + set.add(handler); + handlers.set(type, set); + return () => set.delete(handler); + }, + emit(type, event) { + for (const handler of handlers.get(type) ?? []) handler(event); + }, + }; +} + +function updateInboxItem(state, inboxItemId, nextState, reason, availableAt) { + const item = state.inboxItems.find((candidate) => candidate.id === inboxItemId); + if (item) { + item.state = nextState; + item.reason = reason; + item.availableAt = availableAt; + } + return { + supported: false, + action: nextState === 'failed' ? 'fail' : nextState === 'deferred' ? 'defer' : 'ack', + messageId: item?.message?.id ?? inboxItemId, + reason, + deferUntil: availableAt, + }; +} + +function publicAgent(agent) { + return { + id: agent.id, + name: agent.name, + type: agent.type, + status: agent.status, + persona: agent.persona, + metadata: agent.metadata ?? {}, + createdAt: agent.createdAt, + channels: [], + }; +} + +function publicChannel(channel) { + return { + id: channel.id, + name: channel.name, + topic: channel.topic, + metadata: channel.metadata ?? {}, + archived: Boolean(channel.archived), + memberCount: channel.members.size, + members: [...channel.members] + .sort(compareStrings) + .map((name) => ({ agentId: name, agentName: name, role: 'member', muted: false })), + }; +} + +function publicMessage(message) { + return { + ...message, + reactions: message.reactions ?? [], + readers: [...(message.readers ?? new Set())], + readByCount: message.readers?.size ?? 0, + }; +} + +function requireAgent(state, name) { + const agent = state.agents.get(normalizeAgentName(name)); + if (!agent) throw codedError('agent_not_found', `agent not found: ${name}`); + return agent; +} + +function requireChannel(state, name) { + const channel = state.channels.get(normalizeChannelName(name)); + if (!channel) throw codedError('channel_not_found', `channel not found: ${name}`); + return channel; +} + +function requireMessage(state, id) { + const message = state.messages.get(id); + if (!message) throw codedError('message_not_found', `message not found: ${id}`); + return message; +} + +function ensureReaction(message, emoji) { + let reaction = message.reactions.find((candidate) => candidate.emoji === emoji); + if (!reaction) { + reaction = { emoji, count: 0, agents: [] }; + message.reactions.push(reaction); + } + return reaction; +} + +function lastMessageInChannel(state, channel) { + return [...state.messages.values()] + .filter((message) => message.channel?.name === normalizeChannelName(channel)) + .at(-1); +} + +function agentNameFromToken(state, token) { + return ( + [...state.agents.values()].find((agent) => agent.token === token)?.name ?? + String(token ?? '').replace(/^at_eval_/, '') + ); +} + +function makeErrorFixture(input) { + if (input?.code || input?.message || input?.status || input?.statusCode) { + return Object.assign(new Error(input.message ?? ''), input); + } + if (input?.invalidToken) + return Object.assign(new Error('Invalid agent token'), { code: 'AGENT_TOKEN_INVALID' }); + return input; +} + +function emptyIntegrations() { + const list = async () => []; + return { + webhooks: { + create: async (input) => ({ id: 'wh_eval', ...input }), + list, + delete: async () => {}, + trigger: async (_id, payload) => payload, + }, + subscriptions: { + create: async (input) => ({ id: 'sub_eval', ...input }), + list, + get: async (id) => ({ id }), + delete: async () => {}, + }, + }; +} + +function emptyWebhooks() { + const list = async () => []; + return { + createInbound: async (input) => ({ + webhookId: 'in_wh_eval', + url: 'https://eval.invalid/webhook', + token: 'tok_eval', + channel: input.channel, + name: input.name, + }), + subscribe: async (input) => ({ id: 'sub_eval', ...input }), + list, + delete: async () => {}, + subscriptions: list, + unsubscribe: async () => {}, + }; +} + +function limit(items, count) { + return count ? items.slice(0, count) : items; +} + +function normalizeChannelName(name) { + return String(name ?? '').replace(/^#/, ''); +} + +function normalizeAgentName(name) { + return String(name ?? '').replace(/^@/, ''); +} + +function dmIdFor(participants) { + return `dm_${participants.map(normalizeAgentName).sort(compareStrings).join('_')}`; +} + +function required(value, name) { + if (value === undefined || value === null || value === '') + throw codedError('invalid_args', `${name} is required`); + return value; +} + +function codedError(code, message) { + return Object.assign(new Error(message), { code }); +} + +function normalizeError(error) { + if (error?.name === 'RelayCapabilityError') { + const capability = + error.capability ?? + (String(error.message).includes('server-backed delivery state') + ? 'messaging.capabilities.serverDeliveryState' + : undefined); + return { + code: 'relay_capability_error', + message: `RelayCapabilityError${capability ? ` ${capability}` : ''}: ${error.message}`, + }; + } + return { + code: error?.code ?? error?.name ?? 'error', + message: error instanceof Error ? error.message : (error?.message ?? String(error)), + }; +} + +function stableStringify(value) { + return JSON.stringify(value, (_key, item) => (item instanceof Set ? [...item] : item), 2); +} + +function compareStrings(left, right) { + return String(left).localeCompare(String(right), 'en'); +} diff --git a/scripts/evals/run-relay-evals.mjs b/scripts/evals/run-relay-evals.mjs new file mode 100644 index 000000000..d9cabda38 --- /dev/null +++ b/scripts/evals/run-relay-evals.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { + assertHumanEvalExpected, + createDefaultHumanEvalExecutors, + createHumanEvalRunRecord, + defaultRedactActual, + humanEvalNeedsReview, + loadDotenv, + loadHumanEvalCasesFromSuitesDir, + matchesHumanEvalFilters, + printHumanEvalRunSummary, + validateHumanEvalCase, + writeHumanEvalRunArtifacts, +} from '@agent-assistant/telemetry/evals'; + +import { assertRelayExpected } from './relay-checks.mjs'; +import { createRelayExecutor } from './relay-executor.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const SUITES_DIR = path.join(ROOT, 'evals', 'suites'); +const RUNS_DIR = path.join(ROOT, '.relay', 'evals', 'runs'); + +loadDotenv(path.join(ROOT, '.env')); + +const args = parseArgs(process.argv.slice(2)); +const mode = args.mode ?? 'offline'; +const allCases = loadHumanEvalCasesFromSuitesDir(SUITES_DIR, { rootDir: ROOT }); +const selectedCases = allCases.filter((testCase) => + matchesHumanEvalFilters(testCase, { + suite: args.suite, + caseId: args.caseId, + tags: args.tags.size > 0 ? args.tags : undefined, + }) +); + +if (args.list) { + listCases(selectedCases); + process.exit(0); +} + +if (selectedCases.length === 0) { + console.log('No eval cases selected.'); + console.log( + 'Add human-authored markdown cases under evals/suites/*/cases.md and run npm run evals:compile.' + ); + console.log('Try: npm run evals:list'); + process.exit(0); +} + +const run = createRunRecord({ selectedCases, mode }); +const executors = { + ...createDefaultHumanEvalExecutors(ROOT), + relay: createRelayExecutor(), +}; + +for (const testCase of selectedCases) { + const trials = readPositiveInt(args.trials ?? testCase.trials, 1); + for (let trialIndex = 0; trialIndex < trials; trialIndex += 1) { + const startedAt = Date.now(); + const executorName = args.executor ?? testCase.executor ?? 'relay'; + const trial = { + id: testCase.id, + suite: testCase.suite, + kind: testCase.kind ?? 'capability', + executor: executorName, + trial: trialIndex + 1, + tags: testCase.tags ?? [], + status: 'failed', + duration_ms: 0, + checks: [], + input: testCase.input, + expected: testCase.expected, + }; + + try { + validateHumanEvalCase(testCase); + const executor = executors[executorName]; + if (executor === undefined) throw new Error(`Unknown executor "${executorName}" for ${testCase.id}`); + + const actual = await executor(testCase, { providerMode: false, rootDir: ROOT }); + const checks = [...assertHumanEvalExpected(testCase, actual), ...assertRelayExpected(testCase, actual)]; + const deterministicPassed = checks.every((check) => check.passed); + const needsHuman = deterministicPassed && humanEvalNeedsReview(testCase); + + run.tests.push({ + ...trial, + status: deterministicPassed ? (needsHuman ? 'needs-human' : 'passed') : 'failed', + actual: redactRelayActual(actual), + checks, + duration_ms: Date.now() - startedAt, + }); + } catch (error) { + run.tests.push({ + ...trial, + status: isSkippedError(error) ? 'skipped' : 'failed', + error: error instanceof Error ? error.message : String(error), + duration_ms: Date.now() - startedAt, + }); + } finally { + writeHumanEvalRunArtifacts(run); + } + } +} + +writeHumanEvalRunArtifacts(run, { final: true }); +printHumanEvalRunSummary(run, { productName: 'Relay Evals', rootDir: ROOT }); +process.exitCode = run.tests.some( + (test) => test.status === 'failed' || (shouldFailOnSkipped(args) && test.status === 'skipped') +) + ? 1 + : 0; + +function parseArgs(argv) { + const parsed = { tags: new Set() }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--list') parsed.list = true; + else if (arg === '--suite') parsed.suite = readOptionValue(argv, ++index, '--suite'); + else if (arg === '--case') parsed.caseId = readOptionValue(argv, ++index, '--case'); + else if (arg === '--executor') parsed.executor = readOptionValue(argv, ++index, '--executor'); + else if (arg === '--tag' || arg === '--tags') { + for (const tag of readOptionValue(argv, ++index, arg).split(',')) { + if (tag.trim()) parsed.tags.add(tag.trim()); + } + } else if (arg === '--trials') parsed.trials = Number(readOptionValue(argv, ++index, '--trials')); + else if (arg === '--mode') parsed.mode = readOptionValue(argv, ++index, '--mode'); + else if (arg === '--fail-on-skipped') parsed.failOnSkipped = true; + else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return parsed; +} + +function readOptionValue(argv, index, option) { + const value = argv[index]; + if (value === undefined || value.startsWith('--')) throw new Error(`${option} requires a value`); + return value; +} + +function printHelp() { + console.log(`Usage: node scripts/evals/run-relay-evals.mjs [options] + +Options: + --list List selected cases without running them. + --suite NAME Run one suite. + --case ID Run one case id. + --tag TAGS Require tag(s), comma-separated. Can be repeated. + --trials N Override trial count for every case. + --executor NAME Override selected cases to run with this executor. + --mode MODE Run mode label, usually offline. + --fail-on-skipped Treat skipped cases as a non-zero exit condition. +`); +} + +function listCases(cases) { + if (cases.length === 0) { + console.log('No eval cases found.'); + return; + } + for (const testCase of cases) { + const tags = + Array.isArray(testCase.tags) && testCase.tags.length > 0 ? ` [${testCase.tags.join(',')}]` : ''; + console.log(`${testCase.id} (${testCase.suite}/${testCase.executor ?? 'relay'})${tags}`); + } +} + +function createRunRecord({ selectedCases, mode }) { + const timestampForName = new Date().toISOString().replace(/[:.]/g, '-'); + const git = getGitInfo(ROOT); + const runName = `${timestampForName}-${sanitize(git.branch)}-${sanitize(mode)}`; + return createHumanEvalRunRecord({ + timestamp: new Date().toISOString(), + branch: git.branch, + gitSha: git.sha, + mode, + selectedCaseCount: selectedCases.length, + runDir: path.join(RUNS_DIR, runName), + }); +} + +function getGitInfo(rootDir) { + const branch = runGitInfoCommand(rootDir, ['rev-parse', '--abbrev-ref', 'HEAD'], 'branch'); + const sha = runGitInfoCommand(rootDir, ['rev-parse', '--short', 'HEAD'], 'sha'); + return { branch, sha }; +} + +function runGitInfoCommand(rootDir, gitArgs, label) { + const result = spawnSync('git', gitArgs, { cwd: rootDir, encoding: 'utf8' }); + const value = result.stdout?.trim(); + if (result.status !== 0 || !value) { + const detail = result.error?.message || result.stderr?.trim() || `status=${result.status ?? 'unknown'}`; + console.warn(`getGitInfo: failed to read ${label} with git ${gitArgs.join(' ')}: ${detail}`); + return 'unknown'; + } + return value; +} + +function shouldFailOnSkipped(args) { + return ( + args.failOnSkipped || + process.env.RELAY_EVAL_FAIL_ON_SKIPPED === '1' || + process.env.HUMAN_EVAL_FAIL_ON_SKIPPED === '1' + ); +} + +function readPositiveInt(raw, fallback) { + const value = Number(raw ?? fallback); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function sanitize(value) { + return String(value) + .replace(/[^a-zA-Z0-9_.-]+/g, '-') + .slice(0, 80); +} + +function redactRelayActual(actual) { + const redacted = defaultRedactActual(actual); + if (!actual || typeof actual !== 'object') return redacted; + return { + ...redacted, + observed: actual.observed, + messages: actual.messages, + channels: actual.channels, + agents: actual.agents, + inboxItems: actual.inboxItems, + workspace: actual.workspace, + }; +} + +function isSkippedError(error) { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'HUMAN_EVAL_SKIPPED' || error.code === 'SAGE_EVAL_SKIPPED') + ); +}