hyperstudy 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hyperstudy-0.4.0/CHANGELOG.md +79 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/PKG-INFO +46 -2
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/README.md +45 -1
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/pyproject.toml +1 -1
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/__init__.py +9 -1
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/_downloads.py +13 -9
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/_types.py +2 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/client.py +204 -13
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/experiments.py +9 -2
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/models.py +134 -23
- hyperstudy-0.4.0/src/hyperstudy/personas.py +103 -0
- hyperstudy-0.4.0/tests/fixtures/agent_decision_detail_response.json +30 -0
- hyperstudy-0.4.0/tests/fixtures/agent_decisions_response.json +63 -0
- hyperstudy-0.4.0/tests/fixtures/agent_decisions_truncated_response.json +26 -0
- hyperstudy-0.4.0/tests/fixtures/agent_runs_response.json +46 -0
- hyperstudy-0.4.0/tests/test_agent_data.py +184 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/test_client.py +17 -15
- hyperstudy-0.4.0/tests/test_deployments_write.py +101 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/test_downloads.py +68 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/test_experiments.py +76 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/test_models.py +133 -0
- hyperstudy-0.4.0/tests/test_personas.py +127 -0
- hyperstudy-0.4.0/tests/test_recordings_download.py +136 -0
- hyperstudy-0.3.0/CHANGELOG.md +0 -49
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/.github/workflows/publish.yml +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/.github/workflows/sync-release-notes.yml +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/.github/workflows/test.yml +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/.gitignore +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/LICENSE +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/docs/superpowers/specs/2026-04-10-recording-downloads-design.md +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/_dataframe.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/_display.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/_http.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/_pagination.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/src/hyperstudy/exceptions.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/__init__.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/conftest.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/deployment_sessions_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/deployment_single_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/deployments_list_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/error_401.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/error_403.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/events_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/experiment.schema.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/experiment_single_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/experiments_list_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/paginated_page1.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/paginated_page2.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/pre_experiment_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/recordings_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/sparse_ratings_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/fixtures/warnings_response.json +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/test_dataframe.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/test_http.py +0 -0
- {hyperstudy-0.3.0 → hyperstudy-0.4.0}/tests/test_pagination.py +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v0.4.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **Agent data access**: new methods for AI-agent experiment data —
|
|
8
|
+
`get_agent_decisions(scope_id, scope="experiment"|"room", detail=, limit=, participant_id=)` (per-turn decision logs plus run-manifest rows), `get_agent_decision(room_id, decision_id)` (single decision with full detail blobs: prompt, reasoning chain, peer-model snapshot, prediction update), and `get_agent_runs(experiment_id)` (run manifests with orphaned-run flags). Emits a warning when the server truncates decisions at the per-room limit.
|
|
9
|
+
- `DataType.AGENT_DECISIONS` and `DataType.AGENT_RUNS` enum entries; `get_all_data` now includes an `agent_decisions` key (room-scope fetch filtered to the participant).
|
|
10
|
+
- **Agent-role experiment authoring**: `Role` accepts `mode="agent"` and `persona_id`; new `AgentConfig` (per-role `role_overrides`, `pacing`, `seed`) and `PromptLayer` models; `Experiment` gains `runtime` and `agent_config` fields. Role-name keys in `role_overrides` are preserved (not camelized) on the wire.
|
|
11
|
+
- **Persona management**: `list_personas`, `get_persona`, `create_persona`, `update_persona` (merge-patch), `delete_persona`, `duplicate_persona`, with typed `Persona` / `Guardrails` builders. Requires API-key scopes `read:personas` / `write:personas`.
|
|
12
|
+
- **Agent deployment launch & control**: `create_deployment` (including agent-only deployments — rooms launch server-side immediately), `get_agent_spend`, `run_more`, `stop_room`, `retry_room`. Requires the `write:deployments` scope.
|
|
13
|
+
|
|
14
|
+
### Fixes
|
|
15
|
+
|
|
16
|
+
- `__version__` now matches the packaged version (was stuck at 0.3.0 while pyproject said 0.3.1).
|
|
17
|
+
|
|
18
|
+
## v0.3.1
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
- `download_recording` / `download_recordings` now mint a signed download URL via the recordings download endpoint (using each recording's `downloadPath`) and fall back to the legacy `downloadUrl`/`url` for older GCS-only recordings. Fixes downloads failing after the backend list endpoint stopped embedding signed URLs.
|
|
23
|
+
|
|
24
|
+
### Fixes
|
|
25
|
+
|
|
26
|
+
- Snake_case→camelCase conversion is now recursive for the raw-dict and `**kwargs` paths: nested keys (e.g. `waiting_room_config={"max_wait_time_ms": 5000}` → `{"waitingRoomConfig": {"maxWaitTimeMs": 5000}}`) and factory `**extra` kwargs are converted. Free-form map fields (`variables`, `roles`, `globalComponents`, `globalComponentsVisibility`) preserve their user-defined keys while still converting nested schema fields. Previously nested/extra snake_case keys were silently dropped on the wire.
|
|
27
|
+
- Downloads are now atomic: each file streams to `<dest>.part` and is renamed on success, so a mid-stream failure never destroys a pre-existing complete file. `skip_existing` again skips when `fileSize` is unknown (an existing file is necessarily complete).
|
|
28
|
+
- `update_experiment()` with no fields now raises `ValueError` instead of issuing an empty PUT.
|
|
29
|
+
- `State.id` enforces `min_length=1`; `Experiment.global_components` keys are validated against `GlobalComponentType` (rejected at construction, still serialized as strings on the wire).
|
|
30
|
+
- `FocusComponent.id` is auto-generated when omitted, including direct construction without a factory helper.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## v0.3.0
|
|
34
|
+
|
|
35
|
+
### Features
|
|
36
|
+
|
|
37
|
+
- Typed experiment builders for programmatic authoring. New top-level exports: `Experiment`, `State`, `FocusComponent`, `Role`, `WaitingRoomConfig`, `DisconnectTimeout`, `InstructionsPage`, `PostExperimentQuestionnaire`, `TransitionRules`, `ComponentType`, `GlobalComponentType`. Snake_case Python fields are converted to the camelCase wire format automatically; unknown top-level fields are preserved via `extra="allow"` so the SDK doesn't block on backend schema additions.
|
|
38
|
+
- Component factory helpers: `show_text`, `show_image`, `show_video`, `vas_rating`, `text_input`, `multiple_choice`, `waiting`, `likert_scale`, `ranking`. Extra kwargs pass through to the component `config` for any backend-supported field.
|
|
39
|
+
- `create_experiment` and `update_experiment` now accept an `experiment=Experiment(...)` argument alongside the existing `**kwargs` form. When both are given, kwargs override builder fields.
|
|
40
|
+
- New `validate_experiment(experiment)` method — dry-run against `POST /experiments/validate`.
|
|
41
|
+
- Schema-drift guard test: vendored copy of `experiment.schema.json` is checked against `ComponentType` / `GlobalComponentType` / required-field declarations so backend additions surface in CI.
|
|
42
|
+
|
|
43
|
+
### Backwards Compatibility
|
|
44
|
+
|
|
45
|
+
- `create_experiment(name=..., **kwargs)` continues to work unchanged.
|
|
46
|
+
- `update_experiment(experiment_id, **kwargs)` continues to work unchanged.
|
|
47
|
+
|
|
48
|
+
### Dependencies
|
|
49
|
+
|
|
50
|
+
- Adds `pydantic>=2.5`.
|
|
51
|
+
|
|
52
|
+
## v0.2.0
|
|
53
|
+
|
|
54
|
+
### Features
|
|
55
|
+
|
|
56
|
+
- Convenience methods for common event categories: `get_questionnaire`, `get_instructions`, `get_consent`
|
|
57
|
+
- Deployment management: `list_deployments`, `get_deployment`, `get_deployment_sessions`
|
|
58
|
+
- API warning surfacing: backend `_warnings` metadata now emitted via Python's `warnings` module
|
|
59
|
+
- `get_all_data` now includes `ratings_sparse`, `questionnaire`, `instructions`, and `consent`
|
|
60
|
+
|
|
61
|
+
### Breaking Changes
|
|
62
|
+
|
|
63
|
+
- `get_all_data` return keys changed: `"ratings"` split into `"ratings_continuous"` and `"ratings_sparse"`
|
|
64
|
+
|
|
65
|
+
## v0.1.0
|
|
66
|
+
|
|
67
|
+
Initial release.
|
|
68
|
+
|
|
69
|
+
### Features
|
|
70
|
+
|
|
71
|
+
- `HyperStudy` client with API key authentication (constructor arg or `HYPERSTUDY_API_KEY` env var)
|
|
72
|
+
- Data retrieval methods: `get_events`, `get_recordings`, `get_chat`, `get_videochat`, `get_sync`, `get_ratings`, `get_components`, `get_participants`, `get_rooms`
|
|
73
|
+
- `get_all_data` convenience method for fetching all data types for a single participant
|
|
74
|
+
- Auto-pagination with `tqdm` progress bars (notebook and terminal)
|
|
75
|
+
- Output format options: `"pandas"` (default), `"polars"`, `"dict"`
|
|
76
|
+
- Experiment management: `list_experiments`, `get_experiment`, `get_experiment_config`, `create_experiment`, `update_experiment`, `delete_experiment`
|
|
77
|
+
- Rich notebook display (`_repr_html_`) for experiment objects
|
|
78
|
+
- Typed exceptions: `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ValidationError`, `ServerError`
|
|
79
|
+
- `health()` method for connection testing
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperstudy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python SDK for the HyperStudy experiment platform API
|
|
5
5
|
Project-URL: Homepage, https://hyperstudy.io
|
|
6
6
|
Project-URL: Documentation, https://docs.hyperstudy.io/developers/python-sdk
|
|
@@ -175,13 +175,57 @@ dep = hs.get_deployment("deployment_id")
|
|
|
175
175
|
sessions = hs.get_deployment_sessions("deployment_id")
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
+
## AI Agents
|
|
179
|
+
|
|
180
|
+
Full agent workflow support: personas (the agent library), agent-role experiment authoring, agent-only deployment launch/control, and agent data.
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from hyperstudy import Persona, PromptLayer, Guardrails, Role, AgentConfig
|
|
184
|
+
|
|
185
|
+
# --- Personas (agent library) ---
|
|
186
|
+
persona = hs.create_persona(persona=Persona(
|
|
187
|
+
name="Curious Undergrad",
|
|
188
|
+
provider="anthropic",
|
|
189
|
+
model="claude-opus-4-8",
|
|
190
|
+
prompt=PromptLayer(persona="You are a curious undergraduate...",
|
|
191
|
+
objective="Converse naturally with your partner."),
|
|
192
|
+
guardrails=Guardrails(max_turns=50, budget_usd=2.0),
|
|
193
|
+
))
|
|
194
|
+
personas = hs.list_personas()
|
|
195
|
+
hs.update_persona(persona["id"], description="Updated") # merge-patch
|
|
196
|
+
|
|
197
|
+
# --- Agent roles in experiments ---
|
|
198
|
+
exp = hs.create_experiment(experiment=Experiment(
|
|
199
|
+
name="Agent study",
|
|
200
|
+
runtime="v2", # required for agent-only deployments
|
|
201
|
+
roles={"partner": Role(mode="agent", persona_id=persona["id"])},
|
|
202
|
+
agent_config=AgentConfig(seed=42),
|
|
203
|
+
states=[...],
|
|
204
|
+
))
|
|
205
|
+
|
|
206
|
+
# --- Agent-only deployment ---
|
|
207
|
+
dep = hs.create_deployment(exp["id"], config={
|
|
208
|
+
"type": "agent-only",
|
|
209
|
+
"agentDeployment": {"rooms": 10, "budgetUsd": 5.0},
|
|
210
|
+
})
|
|
211
|
+
spend = hs.get_agent_spend(dep["id"]) # live spend vs budget
|
|
212
|
+
hs.run_more(dep["id"], rooms=5, budget_usd=2.5) # additive batch
|
|
213
|
+
|
|
214
|
+
# --- Agent data ---
|
|
215
|
+
decisions = hs.get_agent_decisions(exp["id"]) # per-turn logs
|
|
216
|
+
runs = hs.get_agent_runs(exp["id"]) # run manifests
|
|
217
|
+
detail = hs.get_agent_decision("room_id", "participantId_3") # full blobs
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Persona methods need the `read:personas` / `write:personas` API-key scopes; deployment launch/control needs `write:deployments`; agent data reads need `read:events`.
|
|
221
|
+
|
|
178
222
|
## All Data for a Participant
|
|
179
223
|
|
|
180
224
|
```python
|
|
181
225
|
data = hs.get_all_data("participant_id", room_id="room_id")
|
|
182
226
|
# Returns dict with keys: events, recordings, chat, videochat, sync,
|
|
183
227
|
# ratings_continuous, ratings_sparse, components, questionnaire,
|
|
184
|
-
# instructions, consent
|
|
228
|
+
# instructions, consent, agent_decisions
|
|
185
229
|
```
|
|
186
230
|
|
|
187
231
|
## API Key
|
|
@@ -140,13 +140,57 @@ dep = hs.get_deployment("deployment_id")
|
|
|
140
140
|
sessions = hs.get_deployment_sessions("deployment_id")
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
## AI Agents
|
|
144
|
+
|
|
145
|
+
Full agent workflow support: personas (the agent library), agent-role experiment authoring, agent-only deployment launch/control, and agent data.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from hyperstudy import Persona, PromptLayer, Guardrails, Role, AgentConfig
|
|
149
|
+
|
|
150
|
+
# --- Personas (agent library) ---
|
|
151
|
+
persona = hs.create_persona(persona=Persona(
|
|
152
|
+
name="Curious Undergrad",
|
|
153
|
+
provider="anthropic",
|
|
154
|
+
model="claude-opus-4-8",
|
|
155
|
+
prompt=PromptLayer(persona="You are a curious undergraduate...",
|
|
156
|
+
objective="Converse naturally with your partner."),
|
|
157
|
+
guardrails=Guardrails(max_turns=50, budget_usd=2.0),
|
|
158
|
+
))
|
|
159
|
+
personas = hs.list_personas()
|
|
160
|
+
hs.update_persona(persona["id"], description="Updated") # merge-patch
|
|
161
|
+
|
|
162
|
+
# --- Agent roles in experiments ---
|
|
163
|
+
exp = hs.create_experiment(experiment=Experiment(
|
|
164
|
+
name="Agent study",
|
|
165
|
+
runtime="v2", # required for agent-only deployments
|
|
166
|
+
roles={"partner": Role(mode="agent", persona_id=persona["id"])},
|
|
167
|
+
agent_config=AgentConfig(seed=42),
|
|
168
|
+
states=[...],
|
|
169
|
+
))
|
|
170
|
+
|
|
171
|
+
# --- Agent-only deployment ---
|
|
172
|
+
dep = hs.create_deployment(exp["id"], config={
|
|
173
|
+
"type": "agent-only",
|
|
174
|
+
"agentDeployment": {"rooms": 10, "budgetUsd": 5.0},
|
|
175
|
+
})
|
|
176
|
+
spend = hs.get_agent_spend(dep["id"]) # live spend vs budget
|
|
177
|
+
hs.run_more(dep["id"], rooms=5, budget_usd=2.5) # additive batch
|
|
178
|
+
|
|
179
|
+
# --- Agent data ---
|
|
180
|
+
decisions = hs.get_agent_decisions(exp["id"]) # per-turn logs
|
|
181
|
+
runs = hs.get_agent_runs(exp["id"]) # run manifests
|
|
182
|
+
detail = hs.get_agent_decision("room_id", "participantId_3") # full blobs
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Persona methods need the `read:personas` / `write:personas` API-key scopes; deployment launch/control needs `write:deployments`; agent data reads need `read:events`.
|
|
186
|
+
|
|
143
187
|
## All Data for a Participant
|
|
144
188
|
|
|
145
189
|
```python
|
|
146
190
|
data = hs.get_all_data("participant_id", room_id="room_id")
|
|
147
191
|
# Returns dict with keys: events, recordings, chat, videochat, sync,
|
|
148
192
|
# ratings_continuous, ratings_sparse, components, questionnaire,
|
|
149
|
-
# instructions, consent
|
|
193
|
+
# instructions, consent, agent_decisions
|
|
150
194
|
```
|
|
151
195
|
|
|
152
196
|
## API Key
|
|
@@ -30,13 +30,17 @@ from .exceptions import (
|
|
|
30
30
|
ValidationError,
|
|
31
31
|
)
|
|
32
32
|
from .models import (
|
|
33
|
+
AgentConfig,
|
|
33
34
|
ComponentType,
|
|
34
35
|
DisconnectTimeout,
|
|
35
36
|
Experiment,
|
|
36
37
|
FocusComponent,
|
|
37
38
|
GlobalComponentType,
|
|
39
|
+
Guardrails,
|
|
38
40
|
InstructionsPage,
|
|
41
|
+
Persona,
|
|
39
42
|
PostExperimentQuestionnaire,
|
|
43
|
+
PromptLayer,
|
|
40
44
|
Role,
|
|
41
45
|
State,
|
|
42
46
|
TransitionRules,
|
|
@@ -52,7 +56,7 @@ from .models import (
|
|
|
52
56
|
waiting,
|
|
53
57
|
)
|
|
54
58
|
|
|
55
|
-
__version__ = "0.
|
|
59
|
+
__version__ = "0.4.0"
|
|
56
60
|
|
|
57
61
|
__all__ = [
|
|
58
62
|
"HyperStudy",
|
|
@@ -75,6 +79,10 @@ __all__ = [
|
|
|
75
79
|
"State",
|
|
76
80
|
"FocusComponent",
|
|
77
81
|
"Role",
|
|
82
|
+
"AgentConfig",
|
|
83
|
+
"PromptLayer",
|
|
84
|
+
"Persona",
|
|
85
|
+
"Guardrails",
|
|
78
86
|
"TransitionRules",
|
|
79
87
|
"WaitingRoomConfig",
|
|
80
88
|
"DisconnectTimeout",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any
|
|
@@ -38,12 +39,12 @@ def build_filename(recording: dict[str, Any]) -> str:
|
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def download_file(url: str, dest: Path, timeout: int = 300) -> int:
|
|
41
|
-
"""Stream-download *url* to *dest
|
|
42
|
+
"""Stream-download *url* to *dest*; returns bytes written.
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
Downloads to a temporary ``<dest>.part`` and atomically renames on success, so a
|
|
45
|
+
mid-stream failure (HTTP error, disconnect, length mismatch) removes only the temp
|
|
46
|
+
file and leaves any pre-existing complete file at ``dest`` intact. Verifies
|
|
47
|
+
``Content-Length`` when provided.
|
|
47
48
|
"""
|
|
48
49
|
resp = requests.get(url, stream=True, timeout=timeout)
|
|
49
50
|
resp.raise_for_status()
|
|
@@ -57,20 +58,23 @@ def download_file(url: str, dest: Path, timeout: int = 300) -> int:
|
|
|
57
58
|
else:
|
|
58
59
|
expected = None
|
|
59
60
|
|
|
61
|
+
tmp = dest.with_name(dest.name + ".part")
|
|
60
62
|
written = 0
|
|
61
|
-
# BaseException so Ctrl-C also cleans up, not just exceptions.
|
|
62
63
|
try:
|
|
63
|
-
with open(
|
|
64
|
+
with open(tmp, "wb") as fh:
|
|
64
65
|
for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
|
|
65
66
|
fh.write(chunk)
|
|
66
67
|
written += len(chunk)
|
|
67
|
-
|
|
68
68
|
if expected is not None and written != expected:
|
|
69
69
|
raise IOError(
|
|
70
70
|
f"Truncated download: wrote {written} of {expected} bytes for {dest.name}"
|
|
71
71
|
)
|
|
72
|
+
os.replace(tmp, dest) # atomic on the same filesystem
|
|
72
73
|
except BaseException:
|
|
73
|
-
|
|
74
|
+
try:
|
|
75
|
+
tmp.unlink(missing_ok=True) # remove only the temp; never the existing dest
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
74
78
|
raise
|
|
75
79
|
|
|
76
80
|
return written
|
|
@@ -15,9 +15,10 @@ from ._pagination import fetch_all_pages
|
|
|
15
15
|
from ._types import Scope
|
|
16
16
|
from .exceptions import HyperStudyError
|
|
17
17
|
from .experiments import ExperimentMixin
|
|
18
|
+
from .personas import PersonaMixin
|
|
18
19
|
|
|
19
20
|
|
|
20
|
-
class HyperStudy(ExperimentMixin):
|
|
21
|
+
class HyperStudy(ExperimentMixin, PersonaMixin):
|
|
21
22
|
"""Client for the HyperStudy API v3.
|
|
22
23
|
|
|
23
24
|
Args:
|
|
@@ -441,6 +442,177 @@ class HyperStudy(ExperimentMixin):
|
|
|
441
442
|
data = body.get("data", [])
|
|
442
443
|
return self._convert_output(data, output)
|
|
443
444
|
|
|
445
|
+
# ------------------------------------------------------------------
|
|
446
|
+
# Deployments — agent-deployment write surface
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
def create_deployment(
|
|
450
|
+
self,
|
|
451
|
+
experiment_id: str,
|
|
452
|
+
*,
|
|
453
|
+
config: dict[str, Any] | None = None,
|
|
454
|
+
**kwargs: Any,
|
|
455
|
+
) -> dict[str, Any]:
|
|
456
|
+
"""Create a deployment (requires the ``write:deployments`` scope).
|
|
457
|
+
|
|
458
|
+
For an agent-only deployment, pass::
|
|
459
|
+
|
|
460
|
+
hs.create_deployment(
|
|
461
|
+
"exp_123",
|
|
462
|
+
config={
|
|
463
|
+
"name": "Pilot batch",
|
|
464
|
+
"type": "agent-only",
|
|
465
|
+
"agentDeployment": {"rooms": 10, "budgetUsd": 5.0},
|
|
466
|
+
},
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
Agent rooms launch server-side immediately after creation. Preflight
|
|
470
|
+
failures (missing persona binding, missing provider key, unreachable
|
|
471
|
+
custom endpoint) raise :class:`ValidationError` with per-role reasons.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
experiment_id: The experiment to deploy (agent-only requires
|
|
475
|
+
``runtime="v2"`` and at least one agent-mode role).
|
|
476
|
+
config: Deployment config dict (camelCase keys, as above).
|
|
477
|
+
**kwargs: Extra config fields, merged into ``config``.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
The created deployment dict.
|
|
481
|
+
"""
|
|
482
|
+
merged = {**(config or {}), **kwargs}
|
|
483
|
+
body = self._transport.post(
|
|
484
|
+
"deployments", json={"experimentId": experiment_id, "config": merged}
|
|
485
|
+
)
|
|
486
|
+
return body.get("data", {})
|
|
487
|
+
|
|
488
|
+
def get_agent_spend(self, deployment_id: str) -> dict[str, Any]:
|
|
489
|
+
"""Total + per-room agent LLM spend for an agent-only deployment."""
|
|
490
|
+
body = self._transport.get(f"deployments/{deployment_id}/agent-spend")
|
|
491
|
+
return body.get("data", {})
|
|
492
|
+
|
|
493
|
+
def run_more(
|
|
494
|
+
self, deployment_id: str, *, rooms: int, budget_usd: float
|
|
495
|
+
) -> dict[str, Any]:
|
|
496
|
+
"""Launch additional agent rooms on an existing agent-only deployment.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
deployment_id: The deployment to extend.
|
|
500
|
+
rooms: Number of additional rooms.
|
|
501
|
+
budget_usd: Additional budget for this batch (added to the
|
|
502
|
+
deployment's cumulative budget cap).
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
Dict with the launched ``batchId`` and ``requestedRooms``.
|
|
506
|
+
"""
|
|
507
|
+
body = self._transport.post(
|
|
508
|
+
f"deployments/{deployment_id}/run-more",
|
|
509
|
+
json={"rooms": rooms, "budgetUsd": budget_usd},
|
|
510
|
+
)
|
|
511
|
+
return body.get("data", {})
|
|
512
|
+
|
|
513
|
+
def stop_room(self, deployment_id: str, room_id: str) -> None:
|
|
514
|
+
"""Force-end a running agent room."""
|
|
515
|
+
self._transport.post(f"deployments/{deployment_id}/rooms/{room_id}/stop")
|
|
516
|
+
|
|
517
|
+
def retry_room(self, deployment_id: str, room_id: str) -> dict[str, Any]:
|
|
518
|
+
"""Re-spawn a fresh room for a spawn-failed one (reuses the budget pool)."""
|
|
519
|
+
body = self._transport.post(
|
|
520
|
+
f"deployments/{deployment_id}/rooms/{room_id}/retry"
|
|
521
|
+
)
|
|
522
|
+
return body.get("data", {})
|
|
523
|
+
|
|
524
|
+
# ------------------------------------------------------------------
|
|
525
|
+
# Agent data — decisions and run manifests
|
|
526
|
+
# ------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
def get_agent_decisions(
|
|
529
|
+
self,
|
|
530
|
+
scope_id: str,
|
|
531
|
+
*,
|
|
532
|
+
scope: str = "experiment",
|
|
533
|
+
detail: bool = False,
|
|
534
|
+
limit: int | None = None,
|
|
535
|
+
participant_id: str | None = None,
|
|
536
|
+
output: str = "pandas",
|
|
537
|
+
):
|
|
538
|
+
"""Fetch AI-agent decision logs (and run manifests).
|
|
539
|
+
|
|
540
|
+
Every agent turn is logged as a decision record; each agent's run
|
|
541
|
+
also produces one manifest row. Room-scope responses tag rows with
|
|
542
|
+
``_type`` (``"decision"`` or ``"run"``).
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
scope_id: Experiment ID or room ID, depending on ``scope``.
|
|
546
|
+
scope: ``"experiment"`` (default, all rooms) or ``"room"``.
|
|
547
|
+
detail: Include full detail blobs (prompt, reasoning chain,
|
|
548
|
+
peer-model snapshot, prediction update) on each decision row.
|
|
549
|
+
limit: Max decisions per room (server default 5000). When the
|
|
550
|
+
cap is hit a truncation warning is emitted.
|
|
551
|
+
participant_id: Optionally filter rows to one agent participant
|
|
552
|
+
(applied client-side).
|
|
553
|
+
output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``.
|
|
554
|
+
"""
|
|
555
|
+
if scope not in ("experiment", "room"):
|
|
556
|
+
raise ValueError(f"scope must be 'experiment' or 'room', got {scope!r}")
|
|
557
|
+
|
|
558
|
+
params: dict[str, Any] = {}
|
|
559
|
+
if detail:
|
|
560
|
+
params["detail"] = "true"
|
|
561
|
+
if limit is not None:
|
|
562
|
+
params["limit"] = limit
|
|
563
|
+
|
|
564
|
+
body = self._transport.get(
|
|
565
|
+
f"data/agent-decisions/{scope}/{scope_id}", params=params or None
|
|
566
|
+
)
|
|
567
|
+
data = body.get("data", [])
|
|
568
|
+
if (body.get("metadata") or {}).get("truncated"):
|
|
569
|
+
warnings.warn(
|
|
570
|
+
"Agent decisions were truncated by the per-room limit; "
|
|
571
|
+
"pass a higher limit= to fetch more.",
|
|
572
|
+
stacklevel=2,
|
|
573
|
+
)
|
|
574
|
+
if participant_id is not None:
|
|
575
|
+
data = [row for row in data if row.get("participantId") == participant_id]
|
|
576
|
+
return self._convert_output(data, output)
|
|
577
|
+
|
|
578
|
+
def get_agent_decision(
|
|
579
|
+
self, room_id: str, decision_id: str
|
|
580
|
+
) -> dict[str, Any]:
|
|
581
|
+
"""Fetch one agent decision with full detail blobs.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
room_id: Room the decision belongs to.
|
|
585
|
+
decision_id: Decision document ID (``{participantId}_{seq}``).
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
Decision dict including prompt, reasoning chain, peer-model
|
|
589
|
+
snapshot, and prediction update.
|
|
590
|
+
"""
|
|
591
|
+
body = self._transport.get(
|
|
592
|
+
f"data/agent-decisions/room/{room_id}/decision/{decision_id}"
|
|
593
|
+
)
|
|
594
|
+
return body.get("data", {})
|
|
595
|
+
|
|
596
|
+
def get_agent_runs(
|
|
597
|
+
self,
|
|
598
|
+
experiment_id: str,
|
|
599
|
+
*,
|
|
600
|
+
output: str = "pandas",
|
|
601
|
+
):
|
|
602
|
+
"""Fetch agent run manifests for an experiment.
|
|
603
|
+
|
|
604
|
+
One row per agent run: model, provider, token/cost totals, seed,
|
|
605
|
+
persona ID, and code version. Runs whose room ended abnormally are
|
|
606
|
+
flagged as orphaned.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
experiment_id: Experiment ID.
|
|
610
|
+
output: ``"pandas"`` (default), ``"polars"``, or ``"dict"``.
|
|
611
|
+
"""
|
|
612
|
+
body = self._transport.get(f"data/agent-runs/experiment/{experiment_id}")
|
|
613
|
+
data = body.get("data", [])
|
|
614
|
+
return self._convert_output(data, output)
|
|
615
|
+
|
|
444
616
|
# ------------------------------------------------------------------
|
|
445
617
|
# Convenience: all data for a participant
|
|
446
618
|
# ------------------------------------------------------------------
|
|
@@ -470,12 +642,28 @@ class HyperStudy(ExperimentMixin):
|
|
|
470
642
|
"questionnaire": self.get_questionnaire(participant_id, **common),
|
|
471
643
|
"instructions": self.get_instructions(participant_id, **common),
|
|
472
644
|
"consent": self.get_consent(participant_id, **common),
|
|
645
|
+
"agent_decisions": self.get_agent_decisions(
|
|
646
|
+
room_id, scope="room", participant_id=participant_id, output=output
|
|
647
|
+
),
|
|
473
648
|
}
|
|
474
649
|
|
|
475
650
|
# ------------------------------------------------------------------
|
|
476
651
|
# Recording downloads
|
|
477
652
|
# ------------------------------------------------------------------
|
|
478
653
|
|
|
654
|
+
def _download_url_for(self, recording: dict[str, Any]) -> str | None:
|
|
655
|
+
"""Resolve a directly-downloadable URL for a recording.
|
|
656
|
+
|
|
657
|
+
Prefers the auth'd mint endpoint (``downloadPath`` -> short-lived signed GCS
|
|
658
|
+
URL); falls back to a legacy absolute ``downloadUrl``/``url`` for old
|
|
659
|
+
GCS-only recordings.
|
|
660
|
+
"""
|
|
661
|
+
download_path = recording.get("downloadPath")
|
|
662
|
+
if download_path:
|
|
663
|
+
body = self._transport.get(download_path)
|
|
664
|
+
return (body.get("data") or {}).get("url")
|
|
665
|
+
return get_download_url(recording)
|
|
666
|
+
|
|
479
667
|
def download_recording(
|
|
480
668
|
self,
|
|
481
669
|
recording: dict[str, Any],
|
|
@@ -490,7 +678,7 @@ class HyperStudy(ExperimentMixin):
|
|
|
490
678
|
Returns:
|
|
491
679
|
Path to the downloaded file.
|
|
492
680
|
"""
|
|
493
|
-
url =
|
|
681
|
+
url = self._download_url_for(recording)
|
|
494
682
|
if not url:
|
|
495
683
|
raise ValueError("Recording has no downloadUrl or url field")
|
|
496
684
|
|
|
@@ -564,27 +752,30 @@ class HyperStudy(ExperimentMixin):
|
|
|
564
752
|
|
|
565
753
|
local_paths: list[str | None] = []
|
|
566
754
|
statuses: list[str] = []
|
|
755
|
+
failed_count = 0
|
|
567
756
|
|
|
568
757
|
for rec in tqdm(recordings, desc="Downloading recordings", disable=not progress):
|
|
569
758
|
filename = build_filename(rec)
|
|
570
759
|
dest = dest_dir / filename
|
|
571
760
|
|
|
572
|
-
|
|
573
|
-
if not url:
|
|
574
|
-
local_paths.append(None)
|
|
575
|
-
statuses.append("failed")
|
|
576
|
-
warnings.warn(f"Recording {rec.get('recordingId')} has no download URL")
|
|
577
|
-
continue
|
|
578
|
-
|
|
761
|
+
# Check skip before resolving the URL (avoids minting a signed URL unnecessarily).
|
|
579
762
|
if skip_existing and dest.exists():
|
|
580
763
|
expected_size = rec.get("fileSize")
|
|
581
|
-
#
|
|
582
|
-
#
|
|
583
|
-
if expected_size is
|
|
764
|
+
# dest is always a fully-written file (atomic rename), so skip when size is
|
|
765
|
+
# unknown OR matches; only re-download on a definite size mismatch.
|
|
766
|
+
if expected_size is None or dest.stat().st_size == expected_size:
|
|
584
767
|
local_paths.append(str(dest.resolve()))
|
|
585
768
|
statuses.append("skipped")
|
|
586
769
|
continue
|
|
587
770
|
|
|
771
|
+
url = self._download_url_for(rec)
|
|
772
|
+
if not url:
|
|
773
|
+
local_paths.append(None)
|
|
774
|
+
statuses.append("failed")
|
|
775
|
+
failed_count += 1
|
|
776
|
+
warnings.warn(f"Recording {rec.get('recordingId')} has no download URL")
|
|
777
|
+
continue
|
|
778
|
+
|
|
588
779
|
try:
|
|
589
780
|
download_file(url, dest)
|
|
590
781
|
local_paths.append(str(dest.resolve()))
|
|
@@ -592,11 +783,11 @@ class HyperStudy(ExperimentMixin):
|
|
|
592
783
|
except Exception as exc:
|
|
593
784
|
local_paths.append(None)
|
|
594
785
|
statuses.append("failed")
|
|
786
|
+
failed_count += 1
|
|
595
787
|
warnings.warn(
|
|
596
788
|
f"Failed to download recording {rec.get('recordingId')}: {exc}"
|
|
597
789
|
)
|
|
598
790
|
|
|
599
|
-
failed_count = sum(1 for s in statuses if s == "failed")
|
|
600
791
|
if failed_count:
|
|
601
792
|
warnings.warn(
|
|
602
793
|
f"{failed_count}/{len(recordings)} recordings failed to download; "
|
|
@@ -10,6 +10,7 @@ from pydantic.alias_generators import to_camel
|
|
|
10
10
|
from ._display import ExperimentInfo
|
|
11
11
|
from ._http import HttpTransport
|
|
12
12
|
from ._pagination import fetch_all_pages
|
|
13
|
+
from .models import camelize_wire
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
15
16
|
from .models import Experiment
|
|
@@ -37,13 +38,14 @@ def _build_experiment_payload(
|
|
|
37
38
|
elif isinstance(experiment, BaseModel):
|
|
38
39
|
payload = experiment.model_dump(by_alias=True, exclude_none=True)
|
|
39
40
|
elif isinstance(experiment, dict):
|
|
40
|
-
payload =
|
|
41
|
+
payload = camelize_wire(experiment)
|
|
41
42
|
else:
|
|
42
43
|
raise TypeError(
|
|
43
44
|
f"experiment must be an Experiment or dict, got {type(experiment).__name__}"
|
|
44
45
|
)
|
|
45
46
|
for key, value in overrides.items():
|
|
46
|
-
|
|
47
|
+
ck = to_camel(key)
|
|
48
|
+
payload[ck] = camelize_wire(value, _parent_key=ck)
|
|
47
49
|
return payload
|
|
48
50
|
|
|
49
51
|
|
|
@@ -182,6 +184,11 @@ class ExperimentMixin:
|
|
|
182
184
|
Update confirmation dict.
|
|
183
185
|
"""
|
|
184
186
|
payload = _build_experiment_payload(experiment, **kwargs)
|
|
187
|
+
if not payload:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
"update_experiment requires at least one field to update "
|
|
190
|
+
"(pass experiment=... or keyword fields)."
|
|
191
|
+
)
|
|
185
192
|
body = self._transport.put(f"experiments/{experiment_id}", json=payload)
|
|
186
193
|
data = body.get("data", [])
|
|
187
194
|
return data[0] if isinstance(data, list) and data else data
|