Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions python/everruns_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
extract_tool_calls,
generate_agent_id,
generate_harness_id,
validate_harness_name,
)

__all__ = [
Expand Down Expand Up @@ -82,6 +83,7 @@
"extract_tool_calls",
"generate_agent_id",
"generate_harness_id",
"validate_harness_name",
]

__version__ = "0.1.0"
15 changes: 15 additions & 0 deletions python/everruns_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
ResumeSessionResponse,
Session,
SessionFile,
validate_harness_name,
)
from everruns_sdk.sse import EventStream, StreamOptions

Expand Down Expand Up @@ -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,
Expand All @@ -383,16 +385,29 @@ 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``).
model_id: LLM model ID override.
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,
Expand Down
24 changes: 24 additions & 0 deletions python/everruns_sdk/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
import secrets
from typing import Any, Literal, Optional

Expand All @@ -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."""

Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions rust/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,14 @@ impl<'a> SessionsClient<'a> {

/// Create a session with full options
pub async fn create_with_options(&self, req: CreateSessionRequest) -> Result<Session> {
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
}

Expand Down
4 changes: 4 additions & 0 deletions rust/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
39 changes: 39 additions & 0 deletions rust/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,43 @@ 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]
pub struct CreateSessionRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub harness_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
Expand All @@ -307,6 +337,7 @@ impl CreateSessionRequest {
pub fn new() -> Self {
Self {
harness_id: None,
harness_name: None,
agent_id: None,
title: None,
locale: None,
Expand All @@ -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<String>) -> Self {
self.harness_name = Some(harness_name.into());
self
}

/// Set the agent ID
pub fn agent_id(mut self, agent_id: impl Into<String>) -> Self {
self.agent_id = Some(agent_id.into());
Expand Down
57 changes: 56 additions & 1 deletion rust/tests/serialization_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Agent> can be serialized and deserialized (round-trip)
Expand Down Expand Up @@ -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() {
Expand Down
13 changes: 10 additions & 3 deletions specs/api-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
StreamOptions,
TopUpRequest,
UpdateBudgetRequest,
validateHarnessName,
} from "./models.js";
import {
ApiError,
Expand Down Expand Up @@ -249,10 +250,19 @@ class SessionsClient {
constructor(private readonly client: Everruns) {}

async create(request: CreateSessionRequest = {}): Promise<Session> {
if (request.harnessId && request.harnessName) {
throw new Error("Cannot specify both harnessId and harnessName");
}
if (request.harnessName) {
validateHarnessName(request.harnessName);
}
const body: Record<string, unknown> = {};
if (request.harnessId) {
body.harness_id = request.harnessId;
}
if (request.harnessName) {
body.harness_name = request.harnessName;
}
if (request.agentId) {
body.agent_id = request.agentId;
}
Expand Down
Loading
Loading