diff --git a/python/everruns_sdk/__init__.py b/python/everruns_sdk/__init__.py index be1cc94..d230039 100644 --- a/python/everruns_sdk/__init__.py +++ b/python/everruns_sdk/__init__.py @@ -45,6 +45,7 @@ extract_tool_calls, generate_agent_id, generate_harness_id, + validate_harness_name, ) __all__ = [ @@ -82,6 +83,7 @@ "extract_tool_calls", "generate_agent_id", "generate_harness_id", + "validate_harness_name", ] __version__ = "0.1.0" diff --git a/python/everruns_sdk/client.py b/python/everruns_sdk/client.py index 2b1971e..b028af2 100644 --- a/python/everruns_sdk/client.py +++ b/python/everruns_sdk/client.py @@ -33,6 +33,7 @@ ResumeSessionResponse, Session, SessionFile, + validate_harness_name, ) from everruns_sdk.sse import EventStream, StreamOptions @@ -370,6 +371,7 @@ async def create( self, harness_id: Optional[str] = None, *, + harness_name: Optional[str] = None, agent_id: Optional[str] = None, title: Optional[str] = None, locale: Optional[str] = None, @@ -383,6 +385,10 @@ async def create( Args: harness_id: Harness ID (format: ``harness_<32-hex>``). Optional; server defaults to the Generic harness if omitted. + harness_name: Human-readable harness name (e.g. ``generic``, + ``deep-research``). Preferred over ``harness_id``. + Must match ``[a-z0-9]+(-[a-z0-9]+)*``, max 64 characters. + Cannot be used together with ``harness_id``. agent_id: Agent ID (optional). title: Human-readable title. locale: Session locale (BCP 47, for example ``uk-UA``). @@ -390,9 +396,18 @@ async def create( tags: Tags for organizing sessions. capabilities: Session-level capabilities (additive to agent capabilities). initial_files: Starter files copied into the session workspace. + + Raises: + ValueError: If both ``harness_id`` and ``harness_name`` are provided, + or if ``harness_name`` fails validation. """ + if harness_id is not None and harness_name is not None: + raise ValueError("Cannot specify both harness_id and harness_name") + if harness_name is not None: + validate_harness_name(harness_name) req = CreateSessionRequest( harness_id=harness_id, + harness_name=harness_name, agent_id=agent_id, title=title, locale=locale, diff --git a/python/everruns_sdk/models.py b/python/everruns_sdk/models.py index 2c6507f..8b8e33d 100644 --- a/python/everruns_sdk/models.py +++ b/python/everruns_sdk/models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import secrets from typing import Any, Literal, Optional @@ -18,6 +19,28 @@ def generate_harness_id() -> str: return f"harness_{secrets.token_hex(16)}" +# Harness name validation: lowercase alphanumeric segments separated by hyphens +_HARNESS_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +_HARNESS_NAME_MAX_LENGTH = 64 + + +def validate_harness_name(name: str) -> str: + """Validate a harness name and return it if valid. + + Harness names must match ``[a-z0-9]+(-[a-z0-9]+)*`` and be at most 64 characters. + + Raises: + ValueError: If the name is invalid. + """ + if len(name) > _HARNESS_NAME_MAX_LENGTH: + raise ValueError( + f"harness_name must be at most {_HARNESS_NAME_MAX_LENGTH} characters, got {len(name)}" + ) + if not _HARNESS_NAME_PATTERN.match(name): + raise ValueError(f"harness_name must match pattern [a-z0-9]+(-[a-z0-9]+)*, got {name!r}") + return name + + class AgentCapabilityConfig(BaseModel): """Per-agent capability configuration.""" @@ -116,6 +139,7 @@ class CreateSessionRequest(BaseModel): """Request to create a session.""" harness_id: Optional[str] = None + harness_name: Optional[str] = None agent_id: Optional[str] = None title: Optional[str] = None locale: Optional[str] = None diff --git a/python/tests/test_client.py b/python/tests/test_client.py index b506995..9f8aef0 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -372,6 +372,64 @@ def test_generate_harness_id_unique(): assert id1 != id2 +def test_validate_harness_name_valid(): + """Test validate_harness_name accepts valid names.""" + from everruns_sdk import validate_harness_name + + validate_harness_name("generic") + validate_harness_name("deep-research") + validate_harness_name("my-harness-v2") + validate_harness_name("a1b2") + validate_harness_name("x") + + +def test_validate_harness_name_too_long(): + """Test validate_harness_name rejects names over 64 chars.""" + from everruns_sdk import validate_harness_name + + with pytest.raises(ValueError, match="at most 64 characters"): + validate_harness_name("a" * 65) + + +def test_validate_harness_name_invalid_pattern(): + """Test validate_harness_name rejects invalid patterns.""" + from everruns_sdk import validate_harness_name + + with pytest.raises(ValueError, match="must match pattern"): + validate_harness_name("UPPER") + with pytest.raises(ValueError, match="must match pattern"): + validate_harness_name("has_underscore") + with pytest.raises(ValueError, match="must match pattern"): + validate_harness_name("-leading-dash") + with pytest.raises(ValueError, match="must match pattern"): + validate_harness_name("trailing-dash-") + with pytest.raises(ValueError, match="must match pattern"): + validate_harness_name("double--dash") + with pytest.raises(ValueError, match="must match pattern"): + validate_harness_name("") + + +def test_create_session_request_with_harness_name(): + """Test CreateSessionRequest serialization with harness_name.""" + from everruns_sdk.models import CreateSessionRequest + + req = CreateSessionRequest(harness_name="deep-research") + data = req.model_dump(exclude_none=True) + assert data["harness_name"] == "deep-research" + assert "harness_id" not in data + + +def test_create_session_request_harness_name_and_id_both(): + """Test that both harness_id and harness_name can be set on the model.""" + from everruns_sdk.models import CreateSessionRequest + + # Both can be set on the model (validation is in the client) + req = CreateSessionRequest(harness_id="harness_abc", harness_name="generic") + data = req.model_dump(exclude_none=True) + assert "harness_id" in data + assert "harness_name" in data + + def test_create_agent_request_with_id(): """Test CreateAgentRequest serialization with client-supplied ID.""" from everruns_sdk.models import CreateAgentRequest, generate_agent_id diff --git a/rust/src/client.rs b/rust/src/client.rs index a063d53..f3ba3b6 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -413,6 +413,14 @@ impl<'a> SessionsClient<'a> { /// Create a session with full options pub async fn create_with_options(&self, req: CreateSessionRequest) -> Result { + if req.harness_id.is_some() && req.harness_name.is_some() { + return Err(Error::Validation( + "Cannot specify both harness_id and harness_name".to_string(), + )); + } + if let Some(ref name) = req.harness_name { + validate_harness_name(name)?; + } self.client.post("/sessions", &req).await } diff --git a/rust/src/error.rs b/rust/src/error.rs index 8e86444..05e8401 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -37,6 +37,10 @@ pub enum Error { #[error("SSE error: {0}")] Sse(String), + /// Client-side validation error + #[error("Validation error: {0}")] + Validation(String), + /// Server-initiated graceful disconnect with retry hint #[error("Graceful disconnect: reason={reason}, retry_ms={retry_ms}")] GracefulDisconnect { reason: String, retry_ms: u64 }, diff --git a/rust/src/models.rs b/rust/src/models.rs index a9477c2..504d24a 100644 --- a/rust/src/models.rs +++ b/rust/src/models.rs @@ -274,6 +274,34 @@ impl InitialFile { } } +/// Harness name validation pattern: lowercase alphanumeric segments separated by hyphens. +/// Max 64 characters. +pub fn validate_harness_name(name: &str) -> crate::error::Result<()> { + const MAX_LEN: usize = 64; + if name.len() > MAX_LEN { + return Err(crate::error::Error::Validation(format!( + "harness_name must be at most {} characters, got {}", + MAX_LEN, + name.len() + ))); + } + // Pattern: [a-z0-9]+(-[a-z0-9]+)* + let valid = !name.is_empty() + && name.split('-').all(|seg| { + !seg.is_empty() + && seg + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + }); + if !valid { + return Err(crate::error::Error::Validation(format!( + "harness_name must match pattern [a-z0-9]+(-[a-z0-9]+)*, got {:?}", + name + ))); + } + Ok(()) +} + /// Request to create a session #[derive(Debug, Clone, Serialize)] #[non_exhaustive] @@ -281,6 +309,8 @@ pub struct CreateSessionRequest { #[serde(skip_serializing_if = "Option::is_none")] pub harness_id: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub harness_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub agent_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, @@ -307,6 +337,7 @@ impl CreateSessionRequest { pub fn new() -> Self { Self { harness_id: None, + harness_name: None, agent_id: None, title: None, locale: None, @@ -323,6 +354,14 @@ impl CreateSessionRequest { self } + /// Set the harness name (preferred over harness_id). + /// Must match `[a-z0-9]+(-[a-z0-9]+)*`, max 64 characters. + /// Cannot be used together with `harness_id`. + pub fn harness_name(mut self, harness_name: impl Into) -> Self { + self.harness_name = Some(harness_name.into()); + self + } + /// Set the agent ID pub fn agent_id(mut self, agent_id: impl Into) -> Self { self.agent_id = Some(agent_id.into()); diff --git a/rust/tests/serialization_test.rs b/rust/tests/serialization_test.rs index 764e5aa..3eba4e4 100644 --- a/rust/tests/serialization_test.rs +++ b/rust/tests/serialization_test.rs @@ -5,7 +5,7 @@ use everruns_sdk::{ Agent, AgentCapabilityConfig, CapabilityInfo, CreateAgentRequest, CreateMessageRequest, CreateSessionRequest, Event, ExternalActor, InitialFile, ListResponse, Message, Session, - generate_agent_id, generate_harness_id, + generate_agent_id, generate_harness_id, validate_harness_name, }; /// Test that ListResponse can be serialized and deserialized (round-trip) @@ -522,6 +522,61 @@ fn test_generate_harness_id_unique() { assert_ne!(id1, id2, "generated IDs should be unique"); } +/// Test validate_harness_name accepts valid names +#[test] +fn test_validate_harness_name_valid() { + assert!(validate_harness_name("generic").is_ok()); + assert!(validate_harness_name("deep-research").is_ok()); + assert!(validate_harness_name("my-harness-v2").is_ok()); + assert!(validate_harness_name("a1b2").is_ok()); + assert!(validate_harness_name("x").is_ok()); +} + +/// Test validate_harness_name rejects names over 64 chars +#[test] +fn test_validate_harness_name_too_long() { + let long_name = "a".repeat(65); + let result = validate_harness_name(&long_name); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("at most 64 characters") + ); +} + +/// Test validate_harness_name rejects invalid patterns +#[test] +fn test_validate_harness_name_invalid_patterns() { + assert!(validate_harness_name("UPPER").is_err()); + assert!(validate_harness_name("has_underscore").is_err()); + assert!(validate_harness_name("-leading-dash").is_err()); + assert!(validate_harness_name("trailing-dash-").is_err()); + assert!(validate_harness_name("double--dash").is_err()); + assert!(validate_harness_name("").is_err()); +} + +/// Test CreateSessionRequest serialization with harness_name +#[test] +fn test_create_session_request_with_harness_name() { + let req = CreateSessionRequest::new().harness_name("deep-research"); + let serialized = serde_json::to_string(&req).expect("should serialize"); + let raw: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!(raw["harness_name"], "deep-research"); + assert!(raw.get("harness_id").is_none()); +} + +/// Test CreateSessionRequest without harness_name omits it +#[test] +fn test_create_session_request_without_harness_name() { + let req = CreateSessionRequest::new().harness_id("harness_abc123"); + let serialized = serde_json::to_string(&req).expect("should serialize"); + let raw: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!(raw["harness_id"], "harness_abc123"); + assert!(raw.get("harness_name").is_none()); +} + /// Test that Event serialization preserves the "type" field name (not "event_type") #[test] fn test_event_type_field_rename() { diff --git a/specs/api-surface.md b/specs/api-surface.md index 0234019..48726d7 100644 --- a/specs/api-surface.md +++ b/specs/api-surface.md @@ -36,10 +36,17 @@ Agent create/update payloads also support optional `initial_files` starter files - `DELETE /v1/sessions/{id}/pin` - Unpin session for current user - `GET /v1/sessions/{id}/export` - Export session messages as JSONL -#### Harness ID +#### Harness Identification -Sessions accept an optional `harness_id` (format: `harness_<32-hex>`). If omitted, the server defaults to the Generic harness. -Use `generate_harness_id()` to create one when needed. Agent is optional on session creation — sessions can run without an agent. +Sessions accept harness identification via one of two parameters (mutually exclusive): + +- **`harness_name`** (preferred): Human-readable name like `generic` or `deep-research`. + Must match `[a-z0-9]+(-[a-z0-9]+)*`, max 64 characters. Client-side validated. +- **`harness_id`**: Opaque ID (format: `harness_<32-hex>`). Use `generate_harness_id()` to create one. + +If neither is provided, the server defaults to the Generic harness. +Providing both `harness_id` and `harness_name` raises a client-side validation error. +Agent is optional on session creation — sessions can run without an agent. Session create/update payloads support optional `title`, `locale`, `model_id`, `tags`, `capabilities`, and `initial_files` starter files. ### Capabilities diff --git a/typescript/src/client.ts b/typescript/src/client.ts index eaf496c..45a3dc2 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -28,6 +28,7 @@ import { StreamOptions, TopUpRequest, UpdateBudgetRequest, + validateHarnessName, } from "./models.js"; import { ApiError, @@ -249,10 +250,19 @@ class SessionsClient { constructor(private readonly client: Everruns) {} async create(request: CreateSessionRequest = {}): Promise { + if (request.harnessId && request.harnessName) { + throw new Error("Cannot specify both harnessId and harnessName"); + } + if (request.harnessName) { + validateHarnessName(request.harnessName); + } const body: Record = {}; if (request.harnessId) { body.harness_id = request.harnessId; } + if (request.harnessName) { + body.harness_name = request.harnessName; + } if (request.agentId) { body.agent_id = request.agentId; } diff --git a/typescript/src/models.ts b/typescript/src/models.ts index 6473a58..3e4962b 100644 --- a/typescript/src/models.ts +++ b/typescript/src/models.ts @@ -102,6 +102,12 @@ export interface InitialFile { export interface CreateSessionRequest { harnessId?: string; + /** + * Human-readable harness name (e.g. "generic", "deep-research"). + * Preferred over harnessId. Must match [a-z0-9]+(-[a-z0-9]+)*, max 64 chars. + * Cannot be used together with harnessId. + */ + harnessName?: string; agentId?: string; title?: string; locale?: string; @@ -111,6 +117,29 @@ export interface CreateSessionRequest { initialFiles?: InitialFile[]; } +/** Harness name validation pattern: lowercase alphanumeric segments separated by hyphens */ +const HARNESS_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const HARNESS_NAME_MAX_LENGTH = 64; + +/** + * Validate a harness name. + * + * @param name - The harness name to validate + * @throws Error if the name is invalid + */ +export function validateHarnessName(name: string): void { + if (name.length > HARNESS_NAME_MAX_LENGTH) { + throw new Error( + `harness_name must be at most ${HARNESS_NAME_MAX_LENGTH} characters, got ${name.length}`, + ); + } + if (!HARNESS_NAME_PATTERN.test(name)) { + throw new Error( + `harness_name must match pattern [a-z0-9]+(-[a-z0-9]+)*, got "${name}"`, + ); + } +} + /** External actor identity for messages from external channels (Slack, Discord, etc.) */ export interface ExternalActor { /** Opaque actor identifier from the source channel */ diff --git a/typescript/tests/client.test.ts b/typescript/tests/client.test.ts index c735517..a85dde0 100644 --- a/typescript/tests/client.test.ts +++ b/typescript/tests/client.test.ts @@ -4,6 +4,7 @@ import { Everruns } from "../src/client.js"; import { generateAgentId, generateHarnessId, + validateHarnessName, extractToolCalls, toolResult, toolError, @@ -367,6 +368,57 @@ describe("generateHarnessId", () => { }); }); +describe("validateHarnessName", () => { + it("should accept valid names", () => { + expect(() => validateHarnessName("generic")).not.toThrow(); + expect(() => validateHarnessName("deep-research")).not.toThrow(); + expect(() => validateHarnessName("my-harness-v2")).not.toThrow(); + expect(() => validateHarnessName("a1b2")).not.toThrow(); + expect(() => validateHarnessName("x")).not.toThrow(); + }); + + it("should reject names over 64 characters", () => { + expect(() => validateHarnessName("a".repeat(65))).toThrow( + "at most 64 characters", + ); + }); + + it("should reject invalid patterns", () => { + expect(() => validateHarnessName("UPPER")).toThrow("must match pattern"); + expect(() => validateHarnessName("has_underscore")).toThrow( + "must match pattern", + ); + expect(() => validateHarnessName("-leading-dash")).toThrow( + "must match pattern", + ); + expect(() => validateHarnessName("trailing-dash-")).toThrow( + "must match pattern", + ); + expect(() => validateHarnessName("double--dash")).toThrow( + "must match pattern", + ); + expect(() => validateHarnessName("")).toThrow("must match pattern"); + }); +}); + +describe("CreateSessionRequest with harnessName", () => { + it("should include harnessName in request interface", () => { + const request: CreateSessionRequest = { + harnessName: "deep-research", + }; + expect(request.harnessName).toBe("deep-research"); + expect(request.harnessId).toBeUndefined(); + }); + + it("should work with harnessId only (backward compat)", () => { + const request: CreateSessionRequest = { + harnessId: "harness_abc123", + }; + expect(request.harnessId).toBe("harness_abc123"); + expect(request.harnessName).toBeUndefined(); + }); +}); + describe("EventsClient URL building", () => { it("should expand exclude as repeated query keys for events list", () => { // Verify the URLSearchParams approach used by EventsClient.list()