Spaces:
Sleeping
Sleeping
Commit ·
f488130
1
Parent(s): f140f88
feat: complete Phase 3 — inference, expert task, deployment
Browse files- Add inference.py with async OrchestratorClient, OpenAI client,
Qwen3 /no_think support, 4-level JSON parser, .env loading
- Add expert bonus task (Life OS Daily Orchestration): 14 subtasks,
8 agents, 2 permanent failure traps, 10-dimension grader
- Add 16 hard task integration tests (walkthrough + edge cases)
- Rewrite README.md with full documentation
- Add root-level requirements.txt
- Populate baseline_scores.json (easy=0.9, medium=0.633, hard=0.808, expert=0.802)
- Fix client.py import fallback for bare module usage
- Deploy to HF Spaces: kartikmandar-workflow-orchestrator.hf.space
- All 5 submission gates pass, 139 tests passing
- README.md +141 -221
- baseline_scores.json +1 -1
- client.py +4 -1
- inference.py +345 -0
- requirements.txt +6 -0
- server/graders.py +186 -0
- server/task_registry.py +185 -0
- tests/test_endpoints.py +3 -3
- tests/test_environment.py +212 -0
- tests/test_task_registry.py +4 -4
README.md
CHANGED
|
@@ -13,243 +13,163 @@ tags:
|
|
| 13 |
|
| 14 |
# Workflow Orchestrator Environment
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
##
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
```
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
```bash
|
| 59 |
-
|
| 60 |
-
|
|
|
|
| 61 |
```
|
| 62 |
|
| 63 |
-
##
|
| 64 |
-
|
| 65 |
-
You can easily deploy your OpenEnv environment to Hugging Face Spaces using the `openenv push` command:
|
| 66 |
|
| 67 |
```bash
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
# Or specify options
|
| 72 |
-
openenv push --namespace my-org --private
|
| 73 |
-
```
|
| 74 |
-
|
| 75 |
-
The `openenv push` command will:
|
| 76 |
-
1. Validate that the directory is an OpenEnv environment (checks for `openenv.yaml`)
|
| 77 |
-
2. Prepare a custom build for Hugging Face Docker space (enables web interface)
|
| 78 |
-
3. Upload to Hugging Face (ensuring you're logged in)
|
| 79 |
-
|
| 80 |
-
### Prerequisites
|
| 81 |
-
|
| 82 |
-
- Authenticate with Hugging Face: The command will prompt for login if not already authenticated
|
| 83 |
-
|
| 84 |
-
### Options
|
| 85 |
-
|
| 86 |
-
- `--directory`, `-d`: Directory containing the OpenEnv environment (defaults to current directory)
|
| 87 |
-
- `--repo-id`, `-r`: Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)
|
| 88 |
-
- `--base-image`, `-b`: Base Docker image to use (overrides Dockerfile FROM)
|
| 89 |
-
- `--private`: Deploy the space as private (default: public)
|
| 90 |
-
|
| 91 |
-
### Examples
|
| 92 |
-
|
| 93 |
-
```bash
|
| 94 |
-
# Push to your personal namespace (defaults to username/env-name from openenv.yaml)
|
| 95 |
-
openenv push
|
| 96 |
-
|
| 97 |
-
# Push to a specific repository
|
| 98 |
-
openenv push --repo-id my-org/my-env
|
| 99 |
-
|
| 100 |
-
# Push with a custom base image
|
| 101 |
-
openenv push --base-image ghcr.io/meta-pytorch/openenv-base:latest
|
| 102 |
-
|
| 103 |
-
# Push as a private space
|
| 104 |
-
openenv push --private
|
| 105 |
-
|
| 106 |
-
# Combine options
|
| 107 |
-
openenv push --repo-id my-org/my-env --base-image custom-base:latest --private
|
| 108 |
-
```
|
| 109 |
-
|
| 110 |
-
After deployment, your space will be available at:
|
| 111 |
-
`https://huggingface.co/spaces/<repo-id>`
|
| 112 |
-
|
| 113 |
-
The deployed space includes:
|
| 114 |
-
- **Web Interface** at `/web` - Interactive UI for exploring the environment
|
| 115 |
-
- **API Documentation** at `/docs` - Full OpenAPI/Swagger interface
|
| 116 |
-
- **Health Check** at `/health` - Container health monitoring
|
| 117 |
-
- **WebSocket** at `/ws` - Persistent session endpoint for low-latency interactions
|
| 118 |
-
|
| 119 |
-
## Environment Details
|
| 120 |
-
|
| 121 |
-
### Action
|
| 122 |
-
**WorkflowOrchestratorAction**: Contains a single field
|
| 123 |
-
- `message` (str) - The message to echo back
|
| 124 |
-
|
| 125 |
-
### Observation
|
| 126 |
-
**WorkflowOrchestratorObservation**: Contains the echo response and metadata
|
| 127 |
-
- `echoed_message` (str) - The message echoed back
|
| 128 |
-
- `message_length` (int) - Length of the message
|
| 129 |
-
- `reward` (float) - Reward based on message length (length × 0.1)
|
| 130 |
-
- `done` (bool) - Always False for echo environment
|
| 131 |
-
- `metadata` (dict) - Additional info like step count
|
| 132 |
-
|
| 133 |
-
### Reward
|
| 134 |
-
The reward is calculated as: `message_length × 0.1`
|
| 135 |
-
- "Hi" → reward: 0.2
|
| 136 |
-
- "Hello, World!" → reward: 1.3
|
| 137 |
-
- Empty message → reward: 0.0
|
| 138 |
-
|
| 139 |
-
## Advanced Usage
|
| 140 |
-
|
| 141 |
-
### Connecting to an Existing Server
|
| 142 |
-
|
| 143 |
-
If you already have a Workflow Orchestrator environment server running, you can connect directly:
|
| 144 |
-
|
| 145 |
-
```python
|
| 146 |
-
from workflow_orchestrator import WorkflowOrchestratorEnv
|
| 147 |
-
|
| 148 |
-
# Connect to existing server
|
| 149 |
-
workflow_orchestratorenv = WorkflowOrchestratorEnv(base_url="<ENV_HTTP_URL_HERE>")
|
| 150 |
-
|
| 151 |
-
# Use as normal
|
| 152 |
-
result = workflow_orchestratorenv.reset()
|
| 153 |
-
result = workflow_orchestratorenv.step(WorkflowOrchestratorAction(message="Hello!"))
|
| 154 |
-
```
|
| 155 |
-
|
| 156 |
-
Note: When connecting to an existing server, `workflow_orchestratorenv.close()` will NOT stop the server.
|
| 157 |
-
|
| 158 |
-
### Using the Context Manager
|
| 159 |
-
|
| 160 |
-
The client supports context manager usage for automatic connection management:
|
| 161 |
-
|
| 162 |
-
```python
|
| 163 |
-
from workflow_orchestrator import WorkflowOrchestratorAction, WorkflowOrchestratorEnv
|
| 164 |
-
|
| 165 |
-
# Connect with context manager (auto-connects and closes)
|
| 166 |
-
with WorkflowOrchestratorEnv(base_url="http://localhost:8000") as env:
|
| 167 |
-
result = env.reset()
|
| 168 |
-
print(f"Reset: {result.observation.echoed_message}")
|
| 169 |
-
# Multiple steps with low latency
|
| 170 |
-
for msg in ["Hello", "World", "!"]:
|
| 171 |
-
result = env.step(WorkflowOrchestratorAction(message=msg))
|
| 172 |
-
print(f"Echoed: {result.observation.echoed_message}")
|
| 173 |
```
|
| 174 |
|
| 175 |
-
|
| 176 |
-
- **Lower latency**: No HTTP connection overhead per request
|
| 177 |
-
- **Persistent session**: Server maintains your environment state
|
| 178 |
-
- **Efficient for episodes**: Better for many sequential steps
|
| 179 |
-
|
| 180 |
-
### Concurrent WebSocket Sessions
|
| 181 |
-
|
| 182 |
-
The server supports multiple concurrent WebSocket connections. To enable this,
|
| 183 |
-
modify `server/app.py` to use factory mode:
|
| 184 |
-
|
| 185 |
-
```python
|
| 186 |
-
# In server/app.py - use factory mode for concurrent sessions
|
| 187 |
-
app = create_app(
|
| 188 |
-
WorkflowOrchestratorEnvironment, # Pass class, not instance
|
| 189 |
-
WorkflowOrchestratorAction,
|
| 190 |
-
WorkflowOrchestratorObservation,
|
| 191 |
-
max_concurrent_envs=4, # Allow 4 concurrent sessions
|
| 192 |
-
)
|
| 193 |
-
```
|
| 194 |
-
|
| 195 |
-
Then multiple clients can connect simultaneously:
|
| 196 |
-
|
| 197 |
-
```python
|
| 198 |
-
from workflow_orchestrator import WorkflowOrchestratorAction, WorkflowOrchestratorEnv
|
| 199 |
-
from concurrent.futures import ThreadPoolExecutor
|
| 200 |
-
|
| 201 |
-
def run_episode(client_id: int):
|
| 202 |
-
with WorkflowOrchestratorEnv(base_url="http://localhost:8000") as env:
|
| 203 |
-
result = env.reset()
|
| 204 |
-
for i in range(10):
|
| 205 |
-
result = env.step(WorkflowOrchestratorAction(message=f"Client {client_id}, step {i}"))
|
| 206 |
-
return client_id, result.observation.message_length
|
| 207 |
-
|
| 208 |
-
# Run 4 episodes concurrently
|
| 209 |
-
with ThreadPoolExecutor(max_workers=4) as executor:
|
| 210 |
-
results = list(executor.map(run_episode, range(4)))
|
| 211 |
-
```
|
| 212 |
-
|
| 213 |
-
## Development & Testing
|
| 214 |
-
|
| 215 |
-
### Direct Environment Testing
|
| 216 |
-
|
| 217 |
-
Test the environment logic directly without starting the HTTP server:
|
| 218 |
|
| 219 |
```bash
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
| 222 |
```
|
| 223 |
|
| 224 |
-
|
| 225 |
-
- Environment resets correctly
|
| 226 |
-
- Step executes actions properly
|
| 227 |
-
- State tracking works
|
| 228 |
-
- Rewards are calculated correctly
|
| 229 |
-
|
| 230 |
-
### Running Locally
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
``
|
| 235 |
-
|
| 236 |
-
``
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
## Project Structure
|
| 239 |
|
| 240 |
```
|
| 241 |
workflow_orchestrator/
|
| 242 |
-
├── .
|
| 243 |
-
├──
|
| 244 |
-
├──
|
| 245 |
-
├──
|
| 246 |
-
├──
|
| 247 |
-
├──
|
| 248 |
-
├──
|
| 249 |
-
├── models.py
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
```
|
|
|
|
| 13 |
|
| 14 |
# Workflow Orchestrator Environment
|
| 15 |
|
| 16 |
+
An OpenEnv environment where an LLM agent acts as a **project coordinator**, managing DAG-based workflows of subtasks across simulated specialist agents with varying capabilities, failure rates, and cost profiles. Tests coordination, delegation, parallelism, failure recovery, and cost management.
|
| 17 |
+
|
| 18 |
+
## Motivation
|
| 19 |
+
|
| 20 |
+
Agent orchestration is the #1 enterprise AI trend — yet LLMs are terrible at it. Research documents 14+ failure modes in multi-agent systems (MAST taxonomy), up to 17x error amplification in unstructured networks (Spark to Fire), and only 25% baseline correctness with GPT-4o (ChatDev). This environment provides a controlled, deterministic testbed for training and evaluating LLM orchestration capabilities across three real-world scenarios: software development, CI/CD deployment, and production incident response.
|
| 21 |
+
|
| 22 |
+
## Action Space
|
| 23 |
+
|
| 24 |
+
| Field | Type | Description |
|
| 25 |
+
|-------|------|-------------|
|
| 26 |
+
| `action_type` | `"delegate"\|"retry"\|"wait"\|"synthesize"\|"abort"` | Which action to take |
|
| 27 |
+
| `subtask_id` | `Optional[str]` | Target subtask (required for delegate/retry/abort) |
|
| 28 |
+
| `agent_name` | `Optional[str]` | Agent to assign (required for delegate/retry) |
|
| 29 |
+
|
| 30 |
+
**Actions:**
|
| 31 |
+
- **delegate**: Assign a ready subtask to an idle, capable agent
|
| 32 |
+
- **retry**: Re-assign a failed subtask (same or different agent)
|
| 33 |
+
- **wait**: Advance time by 1 step; working agents tick
|
| 34 |
+
- **synthesize**: Combine all completed outputs (only valid when all subtasks done)
|
| 35 |
+
- **abort**: Permanently fail a non-completed subtask
|
| 36 |
+
|
| 37 |
+
Invalid actions are accepted but penalized — the step is consumed, a penalty is applied, and state remains unchanged.
|
| 38 |
+
|
| 39 |
+
## Observation Space
|
| 40 |
+
|
| 41 |
+
| Field | Type | Description |
|
| 42 |
+
|-------|------|-------------|
|
| 43 |
+
| `task_description` | `str` | Natural-language task objective |
|
| 44 |
+
| `subtasks` | `list[SubtaskInfo]` | Status of each subtask in the DAG |
|
| 45 |
+
| `agents` | `list[AgentInfo]` | Status of each simulated agent |
|
| 46 |
+
| `completed_outputs` | `dict[str, str]` | Outputs from finished subtasks |
|
| 47 |
+
| `errors` | `list[str]` | Errors from the current step |
|
| 48 |
+
| `time_remaining` | `int` | Steps left before timeout |
|
| 49 |
+
| `time_elapsed` | `int` | Steps taken so far |
|
| 50 |
+
| `capacity_limit` | `int` | Max concurrent in-progress tasks |
|
| 51 |
+
| `active_task_count` | `int` | Currently in-progress task count |
|
| 52 |
+
| `budget_remaining` | `Optional[float]` | Cost budget left (None if unlimited) |
|
| 53 |
+
| `budget_used` | `float` | Cost spent so far |
|
| 54 |
+
| `available_actions` | `list[str]` | Which action types are currently valid |
|
| 55 |
+
| `hint` | `Optional[str]` | One-line suggestion for the agent |
|
| 56 |
+
| `done` | `bool` | Whether the episode has ended |
|
| 57 |
+
| `reward` | `float\|None` | Step reward |
|
| 58 |
+
|
| 59 |
+
## Tasks
|
| 60 |
+
|
| 61 |
+
### Easy: Feature Development Sprint
|
| 62 |
+
- **6 subtasks**: technical_design -> implement_backend -> [implement_frontend, write_tests] -> run_tests -> review_and_merge
|
| 63 |
+
- **4 agents**: All reliable (1.0), speed=1, cost=1.0
|
| 64 |
+
- **Constraints**: time=15, capacity=4, no cost budget
|
| 65 |
+
- **Challenge**: Basic delegation ordering + optional parallel fan-out
|
| 66 |
+
|
| 67 |
+
### Medium: Microservice Deployment Pipeline
|
| 68 |
+
- **9 subtasks**: checkout -> [lint, unit_tests, security_scan] -> build -> push -> staging -> smoke_tests -> production
|
| 69 |
+
- **5 agents**: Varying speed (1-2), cost (1.0-3.0), reliability
|
| 70 |
+
- **Constraints**: time=16, capacity=3, cost_budget=35.0
|
| 71 |
+
- **Challenge**: 3-way parallelism, guaranteed security scan failure requiring retry, cost awareness
|
| 72 |
+
|
| 73 |
+
### Hard: Production Incident Response
|
| 74 |
+
- **10 subtasks**: triage -> [enrich_logs, check_dashboards, check_dependencies] -> root_cause -> hotfix -> validate -> monitor + side channels
|
| 75 |
+
- **7 agents**: Overlapping capabilities, costs 1.0-5.0
|
| 76 |
+
- **Constraints**: time=22, capacity=3, cost_budget=40.0
|
| 77 |
+
- **Challenge**: Permanent failure trap (investigator_alpha on enrich_logs), agent dropout at step 12, SLA milestones, conflicting findings, monitoring patience
|
| 78 |
+
|
| 79 |
+
### Expert (Bonus): Life OS Daily Orchestration
|
| 80 |
+
- **14 subtasks**: morning_check_in -> [assess_sleep, assess_career, assess_personal] -> plan_day -> [focus, inbox] -> [deep_work, handle_urgent] -> midday_health -> resolve_conflict -> [afternoon, notify] -> synthesize_report
|
| 81 |
+
- **8 agents**: Including 2 permanent failure traps (wellness_monitor, executive_assistant)
|
| 82 |
+
- **Constraints**: time=25, capacity=3, cost_budget=55.0
|
| 83 |
+
- **Challenge**: Multi-objective optimization across health/career/personal pillars, career_agent speed degradation at step 7, personal_agent dropout at step 10, 3 SLA milestones, 2 conflict resolution points
|
| 84 |
+
|
| 85 |
+
## Reward Design
|
| 86 |
+
|
| 87 |
+
Dense per-step rewards with 7 positive and 9 negative signals:
|
| 88 |
+
|
| 89 |
+
| Signal | Value | Trigger |
|
| 90 |
+
|--------|-------|---------|
|
| 91 |
+
| correct_delegation | +0.05 | Right agent for right subtask |
|
| 92 |
+
| subtask_completed | +0.08 | Agent finishes successfully |
|
| 93 |
+
| parallelism_exploited | +0.10 | 2+ tasks concurrent |
|
| 94 |
+
| failure_recovered | +0.10 | Retry succeeds after failure |
|
| 95 |
+
| efficient_wait | +0.03 | Wait when nothing delegatable |
|
| 96 |
+
| dependency_violation | -0.10 | Delegate blocked subtask |
|
| 97 |
+
| capacity_violation | -0.15 | Exceed concurrent limit |
|
| 98 |
+
| permanent_retry | -0.06 | Retry permanently failing agent |
|
| 99 |
+
|
| 100 |
+
End-of-episode: +0.20 (all complete + synthesized), +0.10 * time_efficiency, +0.05 * cost_efficiency, -0.10 (incomplete).
|
| 101 |
+
|
| 102 |
+
## Baseline Scores
|
| 103 |
+
|
| 104 |
+
| Task | Score | Model |
|
| 105 |
+
|------|-------|-------|
|
| 106 |
+
| Easy | 0.900 | Qwen/Qwen3-32B |
|
| 107 |
+
| Medium | 0.633 | Qwen/Qwen3-32B |
|
| 108 |
+
| Hard | 0.808 | Qwen/Qwen3-32B |
|
| 109 |
+
| Expert | 0.802 | Qwen/Qwen3-32B |
|
| 110 |
+
|
| 111 |
+
## Setup
|
| 112 |
+
|
| 113 |
+
### Local Development
|
| 114 |
|
| 115 |
```bash
|
| 116 |
+
cd workflow_orchestrator
|
| 117 |
+
uv sync
|
| 118 |
+
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
|
| 119 |
```
|
| 120 |
|
| 121 |
+
### Docker
|
|
|
|
|
|
|
| 122 |
|
| 123 |
```bash
|
| 124 |
+
docker build -t workflow-orchestrator .
|
| 125 |
+
docker run -p 8000:8000 workflow-orchestrator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
```
|
| 127 |
|
| 128 |
+
### Running Inference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
```bash
|
| 131 |
+
export HF_TOKEN=<your-token>
|
| 132 |
+
export API_BASE_URL=https://router.huggingface.co/v1
|
| 133 |
+
export MODEL_NAME=Qwen/Qwen3-32B
|
| 134 |
+
python inference.py
|
| 135 |
```
|
| 136 |
|
| 137 |
+
## API Endpoints
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
| Endpoint | Method | Description |
|
| 140 |
+
|----------|--------|-------------|
|
| 141 |
+
| `/reset` | POST | Reset environment, returns observation |
|
| 142 |
+
| `/step` | POST | Execute action, returns observation |
|
| 143 |
+
| `/state` | GET | Current environment state |
|
| 144 |
+
| `/tasks` | GET | List all available tasks |
|
| 145 |
+
| `/grader` | POST | Grade most recent episode |
|
| 146 |
+
| `/baseline` | POST | Return pre-computed baseline scores |
|
| 147 |
+
| `/health` | GET | Container health check |
|
| 148 |
+
| `/web` | GET | Interactive web interface |
|
| 149 |
+
| `/ws` | WS | WebSocket for persistent sessions |
|
| 150 |
|
| 151 |
## Project Structure
|
| 152 |
|
| 153 |
```
|
| 154 |
workflow_orchestrator/
|
| 155 |
+
├── openenv.yaml # OpenEnv manifest
|
| 156 |
+
├── pyproject.toml # Dependencies and metadata
|
| 157 |
+
├── Dockerfile # Multi-stage Docker build
|
| 158 |
+
├── README.md # This file
|
| 159 |
+
├── inference.py # Baseline inference script
|
| 160 |
+
├── baseline_scores.json # Pre-computed baseline scores
|
| 161 |
+
├── requirements.txt # Pip-compatible dependencies
|
| 162 |
+
├── models.py # Pydantic Action/Observation/State models
|
| 163 |
+
├── client.py # OrchestratorClient (EnvClient subclass)
|
| 164 |
+
├── __init__.py # Module exports
|
| 165 |
+
├── server/
|
| 166 |
+
│ ├── app.py # FastAPI application + custom endpoints
|
| 167 |
+
│ ├── environment.py # Core environment (reset/step/state)
|
| 168 |
+
│ ├── dag_executor.py # DAG state tracking + dependency resolution
|
| 169 |
+
│ ├── agent_pool.py # Simulated agent state machines
|
| 170 |
+
│ ├── reward_calculator.py # Dense reward computation
|
| 171 |
+
│ ├── graders.py # Per-task grading functions
|
| 172 |
+
│ ├── task_registry.py # Easy/medium/hard task configurations
|
| 173 |
+
│ └── observation_formatter.py # Text rendering for LLM consumption
|
| 174 |
+
└── tests/ # 137 passing tests
|
| 175 |
```
|
baseline_scores.json
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"easy":
|
|
|
|
| 1 |
+
{"easy": 0.9, "medium": 0.6325, "hard": 0.8083, "expert": 0.802}
|
client.py
CHANGED
|
@@ -11,7 +11,10 @@ from typing import Dict
|
|
| 11 |
from openenv.core import EnvClient
|
| 12 |
from openenv.core.client_types import StepResult
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class OrchestratorClient(
|
|
|
|
| 11 |
from openenv.core import EnvClient
|
| 12 |
from openenv.core.client_types import StepResult
|
| 13 |
|
| 14 |
+
try:
|
| 15 |
+
from .models import OrchestratorAction, OrchestratorObservation, OrchestratorState
|
| 16 |
+
except ImportError:
|
| 17 |
+
from models import OrchestratorAction, OrchestratorObservation, OrchestratorState
|
| 18 |
|
| 19 |
|
| 20 |
class OrchestratorClient(
|
inference.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""inference.py — Baseline inference for Workflow Orchestrator Environment.
|
| 2 |
+
|
| 3 |
+
MANDATORY STDOUT FORMAT:
|
| 4 |
+
[START] task=<task_name> env=workflow_orchestrator model=<model_name>
|
| 5 |
+
[STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
|
| 6 |
+
[END] success=<true|false> steps=<n> score=<0.000> rewards=<r1,r2,...,rn>
|
| 7 |
+
|
| 8 |
+
Runs a free HF-hosted model against all 3 tasks and reports scores.
|
| 9 |
+
Requires HF_TOKEN (or API_KEY), API_BASE_URL, and MODEL_NAME environment variables.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
import textwrap
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any, Dict, List, Optional
|
| 19 |
+
|
| 20 |
+
import httpx
|
| 21 |
+
from openai import OpenAI
|
| 22 |
+
|
| 23 |
+
from client import OrchestratorClient
|
| 24 |
+
from models import OrchestratorAction, OrchestratorObservation
|
| 25 |
+
|
| 26 |
+
# ── Load .env file if present ──
|
| 27 |
+
|
| 28 |
+
_env_path: Path = Path(__file__).resolve().parent / ".env"
|
| 29 |
+
if _env_path.exists():
|
| 30 |
+
for line in _env_path.read_text().splitlines():
|
| 31 |
+
line = line.strip()
|
| 32 |
+
if line and not line.startswith("#") and "=" in line:
|
| 33 |
+
key, _, value = line.partition("=")
|
| 34 |
+
os.environ.setdefault(key.strip(), value.strip())
|
| 35 |
+
|
| 36 |
+
# ── Configuration (hackathon-mandated env vars) ──
|
| 37 |
+
|
| 38 |
+
API_BASE_URL: str = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
|
| 39 |
+
API_KEY: str = os.getenv("API_KEY") or os.getenv("HF_TOKEN") or ""
|
| 40 |
+
MODEL_NAME: str = os.getenv("MODEL_NAME") or "Qwen/Qwen3-32B"
|
| 41 |
+
IMAGE_NAME: Optional[str] = os.getenv("IMAGE_NAME")
|
| 42 |
+
ENV_URL: str = os.getenv("ENV_URL") or "http://localhost:8000"
|
| 43 |
+
BENCHMARK: str = "workflow_orchestrator"
|
| 44 |
+
TEMPERATURE: float = 0.0
|
| 45 |
+
MAX_TOKENS: int = 200
|
| 46 |
+
MAX_STEPS: int = 50
|
| 47 |
+
SUCCESS_SCORE_THRESHOLD: float = 0.1
|
| 48 |
+
TASK_TIMEOUT_S: int = 300
|
| 49 |
+
|
| 50 |
+
VALID_ACTIONS: set[str] = {"delegate", "retry", "wait", "synthesize", "abort"}
|
| 51 |
+
|
| 52 |
+
llm: OpenAI = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 53 |
+
|
| 54 |
+
SYSTEM_PROMPT: str = textwrap.dedent("""
|
| 55 |
+
You are a workflow orchestrator managing specialist agents to complete a DAG of subtasks.
|
| 56 |
+
|
| 57 |
+
ACTIONS you can take (respond as JSON):
|
| 58 |
+
- delegate(subtask_id, agent_name): Assign a READY subtask to an IDLE agent whose capabilities include the subtask's type.
|
| 59 |
+
- retry(subtask_id, agent_name): Re-assign a FAILED subtask. If the error says "permanent failure" or "lacks required tooling", you MUST pick a DIFFERENT agent — retrying the same one will always fail.
|
| 60 |
+
- wait(): Let time pass. Working agents make progress.
|
| 61 |
+
- synthesize(): Combine all outputs into the final deliverable. Only valid when EVERY subtask is COMPLETED.
|
| 62 |
+
- abort(subtask_id): Permanently cancel a subtask (last resort).
|
| 63 |
+
|
| 64 |
+
STRATEGY TIPS:
|
| 65 |
+
- Maximize parallelism: if multiple READY subtasks exist and idle agents are available, delegate them all before waiting.
|
| 66 |
+
- Respect the capacity limit shown in the status.
|
| 67 |
+
- After a fix is validated, consider waiting a couple of steps to monitor stability before synthesizing.
|
| 68 |
+
- Prefer cheaper agents (lower cost_per_step) when multiple agents can handle the same task type.
|
| 69 |
+
- Don't wait when there are READY subtasks and idle capable agents — that wastes time.
|
| 70 |
+
|
| 71 |
+
Respond with ONLY a JSON object: {"action_type": "...", "subtask_id": "...", "agent_name": "..."} /no_think
|
| 72 |
+
""").strip()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ── Mandatory stdout logging ──
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 79 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 83 |
+
error_val: str = error if error else "null"
|
| 84 |
+
done_val: str = str(done).lower()
|
| 85 |
+
print(
|
| 86 |
+
f"[STEP] step={step} action={action} reward={reward:.2f} done={done_val} error={error_val}",
|
| 87 |
+
flush=True,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 92 |
+
rewards_str: str = ",".join(f"{r:.2f}" for r in rewards)
|
| 93 |
+
print(
|
| 94 |
+
f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}",
|
| 95 |
+
flush=True,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ── Observation formatting ──
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def format_observation(obs: OrchestratorObservation) -> str:
|
| 103 |
+
"""Format observation as structured text for LLM consumption.
|
| 104 |
+
|
| 105 |
+
NOTE: obs.done and obs.reward are defaults on the client side
|
| 106 |
+
(the real values live on StepResult). This function only formats
|
| 107 |
+
workflow state — subtasks, agents, budget, etc.
|
| 108 |
+
"""
|
| 109 |
+
lines: List[str] = []
|
| 110 |
+
time_total: int = obs.time_elapsed + obs.time_remaining
|
| 111 |
+
|
| 112 |
+
budget_str: str = ""
|
| 113 |
+
if obs.budget_remaining is not None:
|
| 114 |
+
budget_total: float = obs.budget_used + obs.budget_remaining
|
| 115 |
+
budget_str = f" | Budget: {obs.budget_used:.1f}/{budget_total:.1f} used"
|
| 116 |
+
|
| 117 |
+
lines.append("=== WORKFLOW ORCHESTRATOR ===")
|
| 118 |
+
lines.append(f"Task: {obs.task_description}")
|
| 119 |
+
lines.append(
|
| 120 |
+
f"Step: {obs.time_elapsed} of {time_total} | "
|
| 121 |
+
f"Active: {obs.active_task_count}/{obs.capacity_limit}{budget_str}"
|
| 122 |
+
)
|
| 123 |
+
lines.append("")
|
| 124 |
+
|
| 125 |
+
# Subtasks
|
| 126 |
+
lines.append("-- SUBTASKS --")
|
| 127 |
+
for s in obs.subtasks:
|
| 128 |
+
label: str = s.status.upper()
|
| 129 |
+
if s.status == "pending" and not s.dependencies_met:
|
| 130 |
+
label = "BLOCKED"
|
| 131 |
+
|
| 132 |
+
detail: str = ""
|
| 133 |
+
if s.status == "completed" and s.output:
|
| 134 |
+
detail = f' -> "{s.output}"'
|
| 135 |
+
elif s.status == "in_progress" and s.assigned_to:
|
| 136 |
+
detail = f" assigned to: {s.assigned_to}"
|
| 137 |
+
if s.steps_remaining is not None:
|
| 138 |
+
detail += f" ({s.steps_remaining} step(s) left)"
|
| 139 |
+
elif s.status == "failed" and s.error:
|
| 140 |
+
detail = f' ERROR: "{s.error}" (attempt {s.attempt_count})'
|
| 141 |
+
elif s.dependencies:
|
| 142 |
+
deps: str = ", ".join(s.dependencies)
|
| 143 |
+
met: str = "met" if s.dependencies_met else "not met"
|
| 144 |
+
detail = f" deps: [{deps}] ({met})"
|
| 145 |
+
|
| 146 |
+
lines.append(f" [{label:12s}] {s.id} (type: {s.type}){detail}")
|
| 147 |
+
lines.append("")
|
| 148 |
+
|
| 149 |
+
# Agents
|
| 150 |
+
lines.append("-- AGENTS --")
|
| 151 |
+
for a in obs.agents:
|
| 152 |
+
caps: str = ", ".join(a.capabilities)
|
| 153 |
+
task_info: str = f" | working on: {a.current_task}" if a.current_task else ""
|
| 154 |
+
lines.append(
|
| 155 |
+
f" [{a.status:8s}] {a.name} ({caps}) "
|
| 156 |
+
f"speed={a.speed} cost={a.cost_per_step:.1f} rel={a.reliability:.2f}{task_info}"
|
| 157 |
+
)
|
| 158 |
+
lines.append("")
|
| 159 |
+
|
| 160 |
+
# Errors
|
| 161 |
+
if obs.errors:
|
| 162 |
+
lines.append("-- ERRORS --")
|
| 163 |
+
for e in obs.errors:
|
| 164 |
+
lines.append(f" ! {e}")
|
| 165 |
+
lines.append("")
|
| 166 |
+
|
| 167 |
+
# Available actions
|
| 168 |
+
lines.append(f"Available actions: {', '.join(obs.available_actions)}")
|
| 169 |
+
|
| 170 |
+
if obs.hint:
|
| 171 |
+
lines.append(f"Hint: {obs.hint}")
|
| 172 |
+
|
| 173 |
+
return "\n".join(lines)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ── Action parsing ──
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def parse_llm_action(response_text: str) -> Dict[str, Any]:
|
| 180 |
+
"""Parse LLM response into action dict. Falls back to wait on failure."""
|
| 181 |
+
if not response_text:
|
| 182 |
+
return {"action_type": "wait"}
|
| 183 |
+
|
| 184 |
+
text: str = response_text.strip()
|
| 185 |
+
|
| 186 |
+
# Strip <think>...</think> tags (Qwen3 thinking mode)
|
| 187 |
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
| 188 |
+
|
| 189 |
+
# Try direct JSON parse
|
| 190 |
+
try:
|
| 191 |
+
parsed: Dict[str, Any] = json.loads(text)
|
| 192 |
+
if isinstance(parsed, dict) and parsed.get("action_type") in VALID_ACTIONS:
|
| 193 |
+
return parsed
|
| 194 |
+
except (json.JSONDecodeError, TypeError):
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
# Try extracting from markdown code block
|
| 198 |
+
code_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
| 199 |
+
if code_match:
|
| 200 |
+
try:
|
| 201 |
+
parsed = json.loads(code_match.group(1))
|
| 202 |
+
if isinstance(parsed, dict) and parsed.get("action_type") in VALID_ACTIONS:
|
| 203 |
+
return parsed
|
| 204 |
+
except (json.JSONDecodeError, TypeError):
|
| 205 |
+
pass
|
| 206 |
+
|
| 207 |
+
# Try extracting any JSON object
|
| 208 |
+
json_match = re.search(r"\{[^{}]*\}", text)
|
| 209 |
+
if json_match:
|
| 210 |
+
try:
|
| 211 |
+
parsed = json.loads(json_match.group(0))
|
| 212 |
+
if isinstance(parsed, dict) and parsed.get("action_type") in VALID_ACTIONS:
|
| 213 |
+
return parsed
|
| 214 |
+
except (json.JSONDecodeError, TypeError):
|
| 215 |
+
pass
|
| 216 |
+
|
| 217 |
+
return {"action_type": "wait"}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ── Task runner ──
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
async def run_task(task_id: str, env: OrchestratorClient) -> float:
|
| 224 |
+
"""Run a single task episode with mandatory stdout logging."""
|
| 225 |
+
log_start(task=task_id, env=BENCHMARK, model=MODEL_NAME)
|
| 226 |
+
|
| 227 |
+
rewards: List[float] = []
|
| 228 |
+
steps_taken: int = 0
|
| 229 |
+
score: float = 0.0
|
| 230 |
+
success: bool = False
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
result = await env.reset(task_id=task_id)
|
| 234 |
+
|
| 235 |
+
for step in range(1, MAX_STEPS + 1):
|
| 236 |
+
if result.done:
|
| 237 |
+
break
|
| 238 |
+
|
| 239 |
+
obs_text: str = format_observation(result.observation)
|
| 240 |
+
|
| 241 |
+
# Call LLM
|
| 242 |
+
try:
|
| 243 |
+
completion = llm.chat.completions.create(
|
| 244 |
+
model=MODEL_NAME,
|
| 245 |
+
messages=[
|
| 246 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 247 |
+
{"role": "user", "content": obs_text},
|
| 248 |
+
],
|
| 249 |
+
temperature=TEMPERATURE,
|
| 250 |
+
max_tokens=MAX_TOKENS,
|
| 251 |
+
stream=False,
|
| 252 |
+
)
|
| 253 |
+
raw_response: str = completion.choices[0].message.content or ""
|
| 254 |
+
except Exception as exc:
|
| 255 |
+
print(f"[DEBUG] LLM call failed: {exc}", flush=True)
|
| 256 |
+
raw_response = ""
|
| 257 |
+
|
| 258 |
+
action_dict: Dict[str, Any] = parse_llm_action(raw_response)
|
| 259 |
+
|
| 260 |
+
action_type: str = action_dict.get("action_type", "wait")
|
| 261 |
+
subtask_id: Optional[str] = action_dict.get("subtask_id")
|
| 262 |
+
agent_name: Optional[str] = action_dict.get("agent_name")
|
| 263 |
+
|
| 264 |
+
action: OrchestratorAction = OrchestratorAction(
|
| 265 |
+
action_type=action_type,
|
| 266 |
+
subtask_id=subtask_id,
|
| 267 |
+
agent_name=agent_name,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
# Format action string for logging
|
| 271 |
+
parts: List[str] = [action_type]
|
| 272 |
+
if subtask_id:
|
| 273 |
+
parts.append(subtask_id)
|
| 274 |
+
if agent_name:
|
| 275 |
+
parts.append(agent_name)
|
| 276 |
+
action_str: str = f"{parts[0]}({','.join(parts[1:])})"
|
| 277 |
+
|
| 278 |
+
result = await env.step(action)
|
| 279 |
+
|
| 280 |
+
reward: float = result.reward or 0.0
|
| 281 |
+
done: bool = result.done
|
| 282 |
+
error: Optional[str] = None
|
| 283 |
+
if result.observation.errors:
|
| 284 |
+
error = result.observation.errors[0]
|
| 285 |
+
|
| 286 |
+
rewards.append(reward)
|
| 287 |
+
steps_taken = step
|
| 288 |
+
|
| 289 |
+
log_step(step=step, action=action_str, reward=reward, done=done, error=error)
|
| 290 |
+
|
| 291 |
+
if done:
|
| 292 |
+
break
|
| 293 |
+
|
| 294 |
+
# Get graded score via HTTP (custom endpoint, not WebSocket)
|
| 295 |
+
try:
|
| 296 |
+
async with httpx.AsyncClient(base_url=ENV_URL, timeout=60.0) as http:
|
| 297 |
+
grade_resp = await http.post("/grader", json={"task_id": task_id})
|
| 298 |
+
grade_data: Dict[str, Any] = grade_resp.json()
|
| 299 |
+
score = grade_data.get("score", 0.0)
|
| 300 |
+
except Exception as exc:
|
| 301 |
+
print(f"[DEBUG] Grader call failed: {exc}", flush=True)
|
| 302 |
+
score = 0.0
|
| 303 |
+
|
| 304 |
+
success = score >= SUCCESS_SCORE_THRESHOLD
|
| 305 |
+
|
| 306 |
+
finally:
|
| 307 |
+
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 308 |
+
|
| 309 |
+
return score
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# ── Main ──
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
async def main() -> None:
|
| 316 |
+
"""Run all 3 tasks sequentially and report scores."""
|
| 317 |
+
if IMAGE_NAME:
|
| 318 |
+
env: OrchestratorClient = await OrchestratorClient.from_docker_image(IMAGE_NAME)
|
| 319 |
+
else:
|
| 320 |
+
env = OrchestratorClient(base_url=ENV_URL)
|
| 321 |
+
|
| 322 |
+
scores: Dict[str, float] = {}
|
| 323 |
+
|
| 324 |
+
try:
|
| 325 |
+
for task_id in ["easy", "medium", "hard", "expert"]:
|
| 326 |
+
try:
|
| 327 |
+
scores[task_id] = await asyncio.wait_for(
|
| 328 |
+
run_task(task_id, env),
|
| 329 |
+
timeout=TASK_TIMEOUT_S,
|
| 330 |
+
)
|
| 331 |
+
except asyncio.TimeoutError:
|
| 332 |
+
print(f"[DEBUG] Task {task_id} timed out after {TASK_TIMEOUT_S}s", flush=True)
|
| 333 |
+
scores[task_id] = 0.0
|
| 334 |
+
log_end(success=False, steps=0, score=0.0, rewards=[])
|
| 335 |
+
finally:
|
| 336 |
+
try:
|
| 337 |
+
await env.close()
|
| 338 |
+
except Exception as exc:
|
| 339 |
+
print(f"[DEBUG] env.close() error: {exc}", flush=True)
|
| 340 |
+
|
| 341 |
+
print(f"\nFinal scores: {json.dumps(scores, indent=2)}")
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
if __name__ == "__main__":
|
| 345 |
+
asyncio.run(main())
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openenv-core[core]>=0.2.2
|
| 2 |
+
fastapi>=0.100.0
|
| 3 |
+
uvicorn>=0.20.0
|
| 4 |
+
pydantic>=2.0
|
| 5 |
+
httpx>=0.24.0
|
| 6 |
+
openai>=1.0.0
|
server/graders.py
CHANGED
|
@@ -287,6 +287,191 @@ def grade_hard(log: EpisodeLog) -> GradeResult:
|
|
| 287 |
)
|
| 288 |
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
# ── Dispatcher ──
|
| 291 |
|
| 292 |
|
|
@@ -294,6 +479,7 @@ _GRADERS = {
|
|
| 294 |
"easy": grade_easy,
|
| 295 |
"medium": grade_medium,
|
| 296 |
"hard": grade_hard,
|
|
|
|
| 297 |
}
|
| 298 |
|
| 299 |
|
|
|
|
| 287 |
)
|
| 288 |
|
| 289 |
|
| 290 |
+
# ── Expert grader helpers ──
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def subtask_completed_check(log: EpisodeLog, subtask_id: str) -> bool:
|
| 294 |
+
"""Check if a specific subtask was completed."""
|
| 295 |
+
for event in log.events:
|
| 296 |
+
if event.event_type == "subtask_completed" and event.data.get("subtask_id") == subtask_id:
|
| 297 |
+
return True
|
| 298 |
+
return False
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def subtask_completed_by_agent(log: EpisodeLog, subtask_id: str, agent_name: str) -> bool:
|
| 302 |
+
"""Check if a subtask was completed by a specific agent."""
|
| 303 |
+
for event in log.events:
|
| 304 |
+
if (
|
| 305 |
+
event.event_type == "subtask_completed"
|
| 306 |
+
and event.data.get("subtask_id") == subtask_id
|
| 307 |
+
and event.data.get("agent_name") == agent_name
|
| 308 |
+
):
|
| 309 |
+
return True
|
| 310 |
+
return False
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def subtask_completed_by_any_agent(
|
| 314 |
+
log: EpisodeLog, subtask_id: str, agent_names: list[str]
|
| 315 |
+
) -> bool:
|
| 316 |
+
"""Check if a subtask was completed by any of the listed agents."""
|
| 317 |
+
for event in log.events:
|
| 318 |
+
if (
|
| 319 |
+
event.event_type == "subtask_completed"
|
| 320 |
+
and event.data.get("subtask_id") == subtask_id
|
| 321 |
+
and event.data.get("agent_name") in agent_names
|
| 322 |
+
):
|
| 323 |
+
return True
|
| 324 |
+
return False
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def subtask_completed_before(log: EpisodeLog, subtask_id: str, deadline_step: int) -> bool:
|
| 328 |
+
"""Check if a subtask completed at or before a given step."""
|
| 329 |
+
for event in log.events:
|
| 330 |
+
if (
|
| 331 |
+
event.event_type == "subtask_completed"
|
| 332 |
+
and event.data.get("subtask_id") == subtask_id
|
| 333 |
+
and event.step <= deadline_step
|
| 334 |
+
):
|
| 335 |
+
return True
|
| 336 |
+
return False
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def subtask_not_completed_by_agent(
|
| 340 |
+
log: EpisodeLog, subtask_id: str, excluded_agents: list[str]
|
| 341 |
+
) -> bool:
|
| 342 |
+
"""Check if subtask was completed by an agent NOT in the excluded list."""
|
| 343 |
+
for event in log.events:
|
| 344 |
+
if (
|
| 345 |
+
event.event_type == "subtask_completed"
|
| 346 |
+
and event.data.get("subtask_id") == subtask_id
|
| 347 |
+
and event.data.get("agent_name") not in excluded_agents
|
| 348 |
+
):
|
| 349 |
+
return True
|
| 350 |
+
return False
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
# ── Expert grader ──
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def grade_expert(log: EpisodeLog) -> GradeResult:
|
| 357 |
+
"""Grade an expert task episode (Life OS Daily Orchestration).
|
| 358 |
+
|
| 359 |
+
10 dimensions: completion(0.15), health(0.12), career(0.10),
|
| 360 |
+
conflict(0.20), cost(0.08), parallelism(0.10), time(0.05),
|
| 361 |
+
error_class(0.08), sla(0.08), communication(0.04).
|
| 362 |
+
"""
|
| 363 |
+
breakdown: dict[str, float] = {}
|
| 364 |
+
|
| 365 |
+
# 1. Completion: 15%
|
| 366 |
+
completed = count_completed_subtasks(log)
|
| 367 |
+
breakdown["completion"] = (completed / 14) * 0.15
|
| 368 |
+
|
| 369 |
+
# 2. Health pillar: 12%
|
| 370 |
+
health_score = 0.0
|
| 371 |
+
if subtask_completed_check(log, "assess_sleep_energy"):
|
| 372 |
+
health_score += 0.3
|
| 373 |
+
if subtask_not_completed_by_agent(log, "midday_health_check", ["wellness_monitor"]):
|
| 374 |
+
health_score += 0.4
|
| 375 |
+
if subtask_completed_before(log, "midday_health_check", 15):
|
| 376 |
+
health_score += 0.3
|
| 377 |
+
breakdown["health_pillar"] = health_score * 0.12
|
| 378 |
+
|
| 379 |
+
# 3. Career throughput: 10%
|
| 380 |
+
career_score = 0.0
|
| 381 |
+
if subtask_completed_check(log, "deep_work_block"):
|
| 382 |
+
career_score += 0.4
|
| 383 |
+
if subtask_completed_check(log, "handle_urgent_request"):
|
| 384 |
+
career_score += 0.3
|
| 385 |
+
if subtask_completed_check(log, "afternoon_execution"):
|
| 386 |
+
career_score += 0.3
|
| 387 |
+
breakdown["career_pillar"] = career_score * 0.10
|
| 388 |
+
|
| 389 |
+
# 4. Conflict resolution: 20% (unique challenge)
|
| 390 |
+
conflict_score = 0.0
|
| 391 |
+
if subtask_completed_by_any_agent(log, "plan_day_schedule", ["companion", "executive_assistant"]):
|
| 392 |
+
conflict_score += 0.3
|
| 393 |
+
if subtask_completed_by_agent(log, "resolve_priority_conflict", "companion"):
|
| 394 |
+
conflict_score += 0.4
|
| 395 |
+
if both_findings_aggregated_expert(log):
|
| 396 |
+
conflict_score += 0.3
|
| 397 |
+
breakdown["conflict_resolution"] = conflict_score * 0.20
|
| 398 |
+
|
| 399 |
+
# 5. Cost efficiency: 8%
|
| 400 |
+
budget_used = get_total_budget_used(log)
|
| 401 |
+
cost_ratio = budget_used / 55.0 if 55.0 > 0 else 0
|
| 402 |
+
if cost_ratio <= 0.75:
|
| 403 |
+
breakdown["cost_efficiency"] = 0.08
|
| 404 |
+
elif cost_ratio <= 1.0:
|
| 405 |
+
breakdown["cost_efficiency"] = (1.0 - cost_ratio) / 0.25 * 0.08
|
| 406 |
+
else:
|
| 407 |
+
breakdown["cost_efficiency"] = 0.0
|
| 408 |
+
|
| 409 |
+
# 6. Parallelism: 10%
|
| 410 |
+
parallel_score = 0.0
|
| 411 |
+
# Morning assessments (3-way)
|
| 412 |
+
if fan_out_parallelism_detected(log, ["assess_sleep_energy", "assess_career_deadlines"]):
|
| 413 |
+
parallel_score += 0.4
|
| 414 |
+
# Focus + inbox (2-way)
|
| 415 |
+
if fan_out_parallelism_detected(log, ["start_focus_session", "process_inbox"]):
|
| 416 |
+
parallel_score += 0.3
|
| 417 |
+
# Afternoon + notify (2-way)
|
| 418 |
+
if fan_out_parallelism_detected(log, ["afternoon_execution", "notify_stakeholders"]):
|
| 419 |
+
parallel_score += 0.3
|
| 420 |
+
breakdown["parallelism"] = parallel_score * 0.10
|
| 421 |
+
|
| 422 |
+
# 7. Time efficiency: 5%
|
| 423 |
+
if episode_completed(log) and log.time_remaining > 0:
|
| 424 |
+
breakdown["time_efficiency"] = (log.time_remaining / 25) * 0.05
|
| 425 |
+
else:
|
| 426 |
+
breakdown["time_efficiency"] = 0.0
|
| 427 |
+
|
| 428 |
+
# 8. Error classification: 8%
|
| 429 |
+
perm_retries = count_retries_on_permanent_failure(log)
|
| 430 |
+
breakdown["error_classification"] = max(0.0, 1.0 - 0.5 * perm_retries) * 0.08
|
| 431 |
+
|
| 432 |
+
# 9. SLA compliance: 8%
|
| 433 |
+
sla_milestones = {"plan_day_schedule": 8, "resolve_priority_conflict": 16, "synthesize_day_report": 23}
|
| 434 |
+
milestones_met = count_sla_milestones_met(log, sla_milestones)
|
| 435 |
+
breakdown["sla_compliance"] = (milestones_met / 3) * 0.08
|
| 436 |
+
|
| 437 |
+
# 10. Communication: 4%
|
| 438 |
+
breakdown["communication"] = 0.04 if subtask_completed_check(log, "notify_stakeholders") else 0.0
|
| 439 |
+
|
| 440 |
+
score = sum(breakdown.values())
|
| 441 |
+
|
| 442 |
+
# Penalty for invalid actions (max -0.15)
|
| 443 |
+
invalid_count = count_invalid_actions(log)
|
| 444 |
+
score -= min(0.15, 0.03 * invalid_count)
|
| 445 |
+
score = max(0.0, min(1.0, score))
|
| 446 |
+
|
| 447 |
+
return GradeResult(
|
| 448 |
+
score=round(score, 4),
|
| 449 |
+
breakdown={k: round(v, 4) for k, v in breakdown.items()},
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def both_findings_aggregated_expert(log: EpisodeLog) -> bool:
|
| 454 |
+
"""Check if both conflict resolution inputs completed before their consumer.
|
| 455 |
+
|
| 456 |
+
For plan_day_schedule: all 3 assessments must complete before it.
|
| 457 |
+
For resolve_priority_conflict: both midday_health_check and handle_urgent_request must complete before it.
|
| 458 |
+
Returns True if at least one conflict point has both inputs resolved.
|
| 459 |
+
"""
|
| 460 |
+
# Check resolve_priority_conflict: needs midday_health_check + handle_urgent_request
|
| 461 |
+
health_done = False
|
| 462 |
+
urgent_done = False
|
| 463 |
+
for event in log.events:
|
| 464 |
+
if event.event_type == "subtask_completed":
|
| 465 |
+
sid = event.data.get("subtask_id", "")
|
| 466 |
+
if sid == "midday_health_check":
|
| 467 |
+
health_done = True
|
| 468 |
+
elif sid == "handle_urgent_request":
|
| 469 |
+
urgent_done = True
|
| 470 |
+
elif sid == "resolve_priority_conflict":
|
| 471 |
+
return health_done and urgent_done
|
| 472 |
+
return False
|
| 473 |
+
|
| 474 |
+
|
| 475 |
# ── Dispatcher ──
|
| 476 |
|
| 477 |
|
|
|
|
| 479 |
"easy": grade_easy,
|
| 480 |
"medium": grade_medium,
|
| 481 |
"hard": grade_hard,
|
| 482 |
+
"expert": grade_expert,
|
| 483 |
}
|
| 484 |
|
| 485 |
|
server/task_registry.py
CHANGED
|
@@ -373,12 +373,197 @@ _HARD_TASK = TaskConfig(
|
|
| 373 |
)
|
| 374 |
|
| 375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
# ── Registry ──
|
| 377 |
|
| 378 |
_TASKS: dict[str, TaskConfig] = {
|
| 379 |
"easy": _EASY_TASK,
|
| 380 |
"medium": _MEDIUM_TASK,
|
| 381 |
"hard": _HARD_TASK,
|
|
|
|
| 382 |
}
|
| 383 |
|
| 384 |
|
|
|
|
| 373 |
)
|
| 374 |
|
| 375 |
|
| 376 |
+
# ── Expert (Bonus): Life OS Daily Orchestration ──
|
| 377 |
+
|
| 378 |
+
_EXPERT_TASK = TaskConfig(
|
| 379 |
+
task_id="expert",
|
| 380 |
+
name="Life OS Daily Orchestration",
|
| 381 |
+
difficulty="expert",
|
| 382 |
+
description="Orchestrate a user's day across health, career, and personal pillars",
|
| 383 |
+
subtask_definitions=[
|
| 384 |
+
{
|
| 385 |
+
"id": "morning_check_in",
|
| 386 |
+
"type": "context",
|
| 387 |
+
"dependencies": [],
|
| 388 |
+
"output_template": "Morning context loaded: sleep 5.2h, 3 meetings, friend's birthday",
|
| 389 |
+
},
|
| 390 |
+
{
|
| 391 |
+
"id": "assess_sleep_energy",
|
| 392 |
+
"type": "health_analysis",
|
| 393 |
+
"dependencies": ["morning_check_in"],
|
| 394 |
+
"output_template": "Sleep analysis: 5.2h (poor), energy low, recommend light day",
|
| 395 |
+
},
|
| 396 |
+
{
|
| 397 |
+
"id": "assess_career_deadlines",
|
| 398 |
+
"type": "career_analysis",
|
| 399 |
+
"dependencies": ["morning_check_in"],
|
| 400 |
+
"output_template": "Career analysis: client deadline Friday, 2 PRs pending review, full effort needed",
|
| 401 |
+
},
|
| 402 |
+
{
|
| 403 |
+
"id": "assess_personal_commitments",
|
| 404 |
+
"type": "personal_analysis",
|
| 405 |
+
"dependencies": ["morning_check_in"],
|
| 406 |
+
"output_template": "Personal analysis: friend's birthday call at 6PM, protect evening",
|
| 407 |
+
},
|
| 408 |
+
{
|
| 409 |
+
"id": "plan_day_schedule",
|
| 410 |
+
"type": "planning",
|
| 411 |
+
"dependencies": ["assess_sleep_energy", "assess_career_deadlines", "assess_personal_commitments"],
|
| 412 |
+
"output_template": "Day plan: light morning focus, deep work 10-12, health break, afternoon push, 6PM call",
|
| 413 |
+
},
|
| 414 |
+
{
|
| 415 |
+
"id": "start_focus_session",
|
| 416 |
+
"type": "focus_setup",
|
| 417 |
+
"dependencies": ["plan_day_schedule"],
|
| 418 |
+
"output_template": "Focus mode: notifications blocked, Slack DND, 90-min timer started",
|
| 419 |
+
},
|
| 420 |
+
{
|
| 421 |
+
"id": "process_inbox",
|
| 422 |
+
"type": "email_triage",
|
| 423 |
+
"dependencies": ["plan_day_schedule"],
|
| 424 |
+
"output_template": "Inbox processed: 3 urgent flagged, 12 archived, 2 delegated",
|
| 425 |
+
},
|
| 426 |
+
{
|
| 427 |
+
"id": "deep_work_block",
|
| 428 |
+
"type": "career_execution",
|
| 429 |
+
"dependencies": ["start_focus_session"],
|
| 430 |
+
"output_template": "Deep work complete: PR #247 submitted, client draft 80% done",
|
| 431 |
+
},
|
| 432 |
+
{
|
| 433 |
+
"id": "handle_urgent_request",
|
| 434 |
+
"type": "career_urgent",
|
| 435 |
+
"dependencies": ["process_inbox"],
|
| 436 |
+
"output_template": "Urgent handled: client deadline moved to Thursday, escalated to manager",
|
| 437 |
+
},
|
| 438 |
+
{
|
| 439 |
+
"id": "midday_health_check",
|
| 440 |
+
"type": "health_alert",
|
| 441 |
+
"dependencies": ["deep_work_block"],
|
| 442 |
+
"output_template": "Health alert: stress critical (HRV 22ms), mandatory 15-min break enforced",
|
| 443 |
+
},
|
| 444 |
+
{
|
| 445 |
+
"id": "resolve_priority_conflict",
|
| 446 |
+
"type": "conflict_resolution",
|
| 447 |
+
"dependencies": ["midday_health_check", "handle_urgent_request"],
|
| 448 |
+
"output_template": "Conflict resolved: 15-min break now, then client push, birthday call preserved at 6PM",
|
| 449 |
+
},
|
| 450 |
+
{
|
| 451 |
+
"id": "afternoon_execution",
|
| 452 |
+
"type": "career_execution",
|
| 453 |
+
"dependencies": ["resolve_priority_conflict"],
|
| 454 |
+
"output_template": "Afternoon complete: client deliverable submitted, PRs reviewed",
|
| 455 |
+
},
|
| 456 |
+
{
|
| 457 |
+
"id": "notify_stakeholders",
|
| 458 |
+
"type": "communication",
|
| 459 |
+
"dependencies": ["resolve_priority_conflict"],
|
| 460 |
+
"output_template": "Notifications sent: manager updated, birthday reminder set, health log saved",
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"id": "synthesize_day_report",
|
| 464 |
+
"type": "communication",
|
| 465 |
+
"dependencies": ["afternoon_execution", "notify_stakeholders"],
|
| 466 |
+
"output_template": "Day report: health managed (break taken), career delivered (client + PRs), personal preserved (6PM call)",
|
| 467 |
+
},
|
| 468 |
+
],
|
| 469 |
+
agent_definitions=[
|
| 470 |
+
{
|
| 471 |
+
"name": "companion",
|
| 472 |
+
"capabilities": [
|
| 473 |
+
"context", "health_analysis", "health_alert", "career_analysis",
|
| 474 |
+
"career_execution", "career_urgent", "personal_analysis", "planning",
|
| 475 |
+
"focus_setup", "email_triage", "conflict_resolution", "communication",
|
| 476 |
+
],
|
| 477 |
+
"speed": 2,
|
| 478 |
+
"reliability": 0.90,
|
| 479 |
+
"cost_per_step": 3.0,
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"name": "health_agent",
|
| 483 |
+
"capabilities": ["context", "health_analysis", "health_alert"],
|
| 484 |
+
"speed": 1,
|
| 485 |
+
"reliability": 0.85,
|
| 486 |
+
"cost_per_step": 1.5,
|
| 487 |
+
},
|
| 488 |
+
{
|
| 489 |
+
"name": "career_agent",
|
| 490 |
+
"capabilities": ["context", "career_analysis", "career_execution", "career_urgent"],
|
| 491 |
+
"speed": 2,
|
| 492 |
+
"reliability": 0.80,
|
| 493 |
+
"cost_per_step": 2.0,
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
"name": "focus_agent",
|
| 497 |
+
"capabilities": ["context", "focus_setup"],
|
| 498 |
+
"speed": 1,
|
| 499 |
+
"reliability": 0.95,
|
| 500 |
+
"cost_per_step": 1.0,
|
| 501 |
+
},
|
| 502 |
+
{
|
| 503 |
+
"name": "mail_agent",
|
| 504 |
+
"capabilities": ["context", "email_triage", "communication"],
|
| 505 |
+
"speed": 1,
|
| 506 |
+
"reliability": 0.85,
|
| 507 |
+
"cost_per_step": 1.0,
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"name": "personal_agent",
|
| 511 |
+
"capabilities": ["context", "personal_analysis", "communication"],
|
| 512 |
+
"speed": 1,
|
| 513 |
+
"reliability": 0.90,
|
| 514 |
+
"cost_per_step": 0.5,
|
| 515 |
+
},
|
| 516 |
+
{
|
| 517 |
+
"name": "wellness_monitor",
|
| 518 |
+
"capabilities": ["context", "health_analysis"],
|
| 519 |
+
"speed": 2,
|
| 520 |
+
"reliability": 0.70,
|
| 521 |
+
"cost_per_step": 1.0,
|
| 522 |
+
},
|
| 523 |
+
{
|
| 524 |
+
"name": "executive_assistant",
|
| 525 |
+
"capabilities": [
|
| 526 |
+
"context", "planning", "career_analysis", "career_urgent",
|
| 527 |
+
"email_triage", "communication",
|
| 528 |
+
],
|
| 529 |
+
"speed": 2,
|
| 530 |
+
"reliability": 0.75,
|
| 531 |
+
"cost_per_step": 2.0,
|
| 532 |
+
},
|
| 533 |
+
],
|
| 534 |
+
constraints={
|
| 535 |
+
"time_budget": 25,
|
| 536 |
+
"capacity_limit": 3,
|
| 537 |
+
"cost_budget": 55.0,
|
| 538 |
+
},
|
| 539 |
+
reliability_overrides={
|
| 540 |
+
# Wellness monitor permanently can't do health_alert (lacks clinical-grade analysis)
|
| 541 |
+
("wellness_monitor", "health_alert"): 0.0,
|
| 542 |
+
# Executive assistant permanently can't do conflict_resolution (can't reason across pillars)
|
| 543 |
+
("executive_assistant", "conflict_resolution"): 0.0,
|
| 544 |
+
},
|
| 545 |
+
scheduled_events=[
|
| 546 |
+
{"step": 7, "event_type": "degradation", "target": "career_agent", "params": {"new_speed": 4}},
|
| 547 |
+
{"step": 10, "event_type": "dropout", "target": "personal_agent", "params": {}},
|
| 548 |
+
],
|
| 549 |
+
sla_milestones={
|
| 550 |
+
"plan_day_schedule": 8,
|
| 551 |
+
"resolve_priority_conflict": 16,
|
| 552 |
+
"synthesize_day_report": 23,
|
| 553 |
+
},
|
| 554 |
+
seed=45,
|
| 555 |
+
sequential_time=14,
|
| 556 |
+
communication_subtasks=["notify_stakeholders", "synthesize_day_report"],
|
| 557 |
+
)
|
| 558 |
+
|
| 559 |
+
|
| 560 |
# ── Registry ──
|
| 561 |
|
| 562 |
_TASKS: dict[str, TaskConfig] = {
|
| 563 |
"easy": _EASY_TASK,
|
| 564 |
"medium": _MEDIUM_TASK,
|
| 565 |
"hard": _HARD_TASK,
|
| 566 |
+
"expert": _EXPERT_TASK,
|
| 567 |
}
|
| 568 |
|
| 569 |
|
tests/test_endpoints.py
CHANGED
|
@@ -12,11 +12,11 @@ def client():
|
|
| 12 |
|
| 13 |
|
| 14 |
class TestTasksEndpoint:
|
| 15 |
-
def
|
| 16 |
resp = client.get("/tasks")
|
| 17 |
assert resp.status_code == 200
|
| 18 |
data = resp.json()
|
| 19 |
-
assert len(data) =
|
| 20 |
|
| 21 |
def test_get_tasks_structure(self, client) -> None:
|
| 22 |
resp = client.get("/tasks")
|
|
@@ -32,7 +32,7 @@ class TestTasksEndpoint:
|
|
| 32 |
def test_get_tasks_ids(self, client) -> None:
|
| 33 |
resp = client.get("/tasks")
|
| 34 |
ids = {t["task_id"] for t in resp.json()}
|
| 35 |
-
assert
|
| 36 |
|
| 37 |
|
| 38 |
class TestGraderEndpoint:
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class TestTasksEndpoint:
|
| 15 |
+
def test_get_tasks_returns_all(self, client) -> None:
|
| 16 |
resp = client.get("/tasks")
|
| 17 |
assert resp.status_code == 200
|
| 18 |
data = resp.json()
|
| 19 |
+
assert len(data) >= 3
|
| 20 |
|
| 21 |
def test_get_tasks_structure(self, client) -> None:
|
| 22 |
resp = client.get("/tasks")
|
|
|
|
| 32 |
def test_get_tasks_ids(self, client) -> None:
|
| 33 |
resp = client.get("/tasks")
|
| 34 |
ids = {t["task_id"] for t in resp.json()}
|
| 35 |
+
assert {"easy", "medium", "hard"}.issubset(ids)
|
| 36 |
|
| 37 |
|
| 38 |
class TestGraderEndpoint:
|
tests/test_environment.py
CHANGED
|
@@ -281,3 +281,215 @@ class TestStateAndLog:
|
|
| 281 |
state = env.state
|
| 282 |
# implement_backend was pending/ready, now failed after abort
|
| 283 |
assert state.subtask_statuses["implement_backend"] == "failed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
state = env.state
|
| 282 |
# implement_backend was pending/ready, now failed after abort
|
| 283 |
assert state.subtask_statuses["implement_backend"] == "failed"
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# ── Hard task walkthrough ──
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
class TestHardTaskWalkthrough:
|
| 290 |
+
"""Full walkthrough of the hard task (Production Incident Response).
|
| 291 |
+
|
| 292 |
+
The sequence was verified against the seeded RNG (seed=44) to produce
|
| 293 |
+
deterministic outcomes. Speed=1 agents complete in the same step as
|
| 294 |
+
delegation; speed=2 agents complete one step later.
|
| 295 |
+
"""
|
| 296 |
+
|
| 297 |
+
def _run_known_good_sequence(self):
|
| 298 |
+
"""Execute the known-good 14-step hard task walkthrough.
|
| 299 |
+
|
| 300 |
+
Returns (env, final_obs) so callers can make assertions.
|
| 301 |
+
"""
|
| 302 |
+
env, obs = _make_env("hard")
|
| 303 |
+
|
| 304 |
+
# S0: triage (speed=1, completes immediately)
|
| 305 |
+
_delegate(env, "alert_triage", "triage_analyst")
|
| 306 |
+
# S1: alpha on enrich_logs (speed=2, will fail permanently)
|
| 307 |
+
_delegate(env, "enrich_logs", "investigator_alpha")
|
| 308 |
+
# S2: monitor on dashboards (speed=1, completes; alpha fails)
|
| 309 |
+
_delegate(env, "check_dashboards", "monitor")
|
| 310 |
+
# S3: retry enrich_logs with beta (speed=2)
|
| 311 |
+
_retry(env, "enrich_logs", "investigator_beta")
|
| 312 |
+
# S4: alpha on check_deps (speed=2; beta completes = recovery)
|
| 313 |
+
_delegate(env, "check_dependencies", "investigator_alpha")
|
| 314 |
+
# S5: communicator on notify (speed=1; alpha completes check_deps)
|
| 315 |
+
_delegate(env, "notify_stakeholders", "communicator")
|
| 316 |
+
# S6: senior on root_cause (speed=1, completes; SLA met step 6 ≤ 10)
|
| 317 |
+
_delegate(env, "root_cause_analysis", "senior_engineer")
|
| 318 |
+
# S7: deployer on hotfix (speed=2)
|
| 319 |
+
_delegate(env, "deploy_hotfix", "deployer")
|
| 320 |
+
# S8: communicator on status_page (speed=1; deployer completes)
|
| 321 |
+
_delegate(env, "update_status_page", "communicator")
|
| 322 |
+
# S9: senior on validate_fix (speed=1, completes)
|
| 323 |
+
_delegate(env, "validate_fix", "senior_engineer")
|
| 324 |
+
# S10: monitor on monitor_recovery (speed=1, completes; all 10 done)
|
| 325 |
+
_delegate(env, "monitor_recovery", "monitor")
|
| 326 |
+
# S11-12: monitoring patience waits (2 consecutive waits)
|
| 327 |
+
_wait(env)
|
| 328 |
+
_wait(env) # deployer dropout fires at step 12 (idle, no impact)
|
| 329 |
+
# S13: synthesize
|
| 330 |
+
obs = _synthesize(env)
|
| 331 |
+
|
| 332 |
+
return env, obs
|
| 333 |
+
|
| 334 |
+
def test_all_subtasks_complete(self) -> None:
|
| 335 |
+
"""All 10 subtasks should be completed after the walkthrough."""
|
| 336 |
+
env, obs = self._run_known_good_sequence()
|
| 337 |
+
assert obs.done is True
|
| 338 |
+
assert len(obs.completed_outputs) == 10
|
| 339 |
+
assert env._dag.is_all_completed()
|
| 340 |
+
|
| 341 |
+
def test_episode_terminates_with_bonus(self) -> None:
|
| 342 |
+
"""Synthesize should trigger done + positive end bonus."""
|
| 343 |
+
env, obs = self._run_known_good_sequence()
|
| 344 |
+
assert obs.done is True
|
| 345 |
+
assert obs.reward > 0 # end bonus included
|
| 346 |
+
assert env._total_reward > 2.0 # verified: 2.2589
|
| 347 |
+
|
| 348 |
+
def test_time_and_budget(self) -> None:
|
| 349 |
+
"""Verify time remaining and budget used match expected values."""
|
| 350 |
+
env, obs = self._run_known_good_sequence()
|
| 351 |
+
assert obs.time_remaining == 8 # 22 - 14 steps
|
| 352 |
+
assert env._pool.get_budget_used() == pytest.approx(30.0, abs=0.1)
|
| 353 |
+
|
| 354 |
+
def test_permanent_failure_and_recovery(self) -> None:
|
| 355 |
+
"""Alpha fails permanently on enrich_logs, beta recovers."""
|
| 356 |
+
env, obs = self._run_known_good_sequence()
|
| 357 |
+
assert env._failures_occurred == 1 # alpha on enrich_logs
|
| 358 |
+
assert env._failures_recovered == 1 # beta succeeds on retry
|
| 359 |
+
|
| 360 |
+
def test_parallelism_detected(self) -> None:
|
| 361 |
+
"""Should detect parallelism when 2+ tasks run concurrently."""
|
| 362 |
+
env, obs = self._run_known_good_sequence()
|
| 363 |
+
assert env._parallelism_events >= 3 # verified: 4
|
| 364 |
+
|
| 365 |
+
def test_deployer_goes_offline(self) -> None:
|
| 366 |
+
"""Deployer should be offline after step 12 dropout event."""
|
| 367 |
+
env, obs = self._run_known_good_sequence()
|
| 368 |
+
assert env._pool.is_online("deployer") is False
|
| 369 |
+
|
| 370 |
+
def test_zero_capacity_violations(self) -> None:
|
| 371 |
+
"""No capacity violations in the known-good sequence."""
|
| 372 |
+
env, obs = self._run_known_good_sequence()
|
| 373 |
+
assert env._capacity_violations == 0
|
| 374 |
+
|
| 375 |
+
def test_grader_score_above_threshold(self) -> None:
|
| 376 |
+
"""Grader should score > 0.80 for the known-good walkthrough."""
|
| 377 |
+
from server.graders import grade_hard
|
| 378 |
+
|
| 379 |
+
env, obs = self._run_known_good_sequence()
|
| 380 |
+
log = _episode_store["hard"]
|
| 381 |
+
result = grade_hard(log)
|
| 382 |
+
assert result.score > 0.80
|
| 383 |
+
assert result.score <= 1.0
|
| 384 |
+
|
| 385 |
+
def test_grader_all_dimensions_present(self) -> None:
|
| 386 |
+
"""All 9 grader dimensions should be present and non-negative."""
|
| 387 |
+
from server.graders import grade_hard
|
| 388 |
+
|
| 389 |
+
env, obs = self._run_known_good_sequence()
|
| 390 |
+
log = _episode_store["hard"]
|
| 391 |
+
result = grade_hard(log)
|
| 392 |
+
|
| 393 |
+
expected_keys = [
|
| 394 |
+
"completion", "recovery", "error_classification",
|
| 395 |
+
"capacity_discipline", "parallelism", "cost_efficiency",
|
| 396 |
+
"conflict_resolution", "sla_compliance", "monitoring_patience",
|
| 397 |
+
]
|
| 398 |
+
for key in expected_keys:
|
| 399 |
+
assert key in result.breakdown, f"Missing grader dimension: {key}"
|
| 400 |
+
assert result.breakdown[key] >= 0.0, f"{key} is negative"
|
| 401 |
+
|
| 402 |
+
def test_grader_perfect_dimensions(self) -> None:
|
| 403 |
+
"""Verify the known-good walkthrough scores perfectly on key dimensions."""
|
| 404 |
+
from server.graders import grade_hard
|
| 405 |
+
|
| 406 |
+
env, obs = self._run_known_good_sequence()
|
| 407 |
+
log = _episode_store["hard"]
|
| 408 |
+
result = grade_hard(log)
|
| 409 |
+
|
| 410 |
+
assert result.breakdown["completion"] == pytest.approx(0.20, abs=0.01)
|
| 411 |
+
assert result.breakdown["recovery"] == pytest.approx(0.15, abs=0.01)
|
| 412 |
+
assert result.breakdown["error_classification"] == pytest.approx(0.10, abs=0.01)
|
| 413 |
+
assert result.breakdown["capacity_discipline"] == pytest.approx(0.10, abs=0.01)
|
| 414 |
+
assert result.breakdown["conflict_resolution"] == pytest.approx(0.10, abs=0.01)
|
| 415 |
+
assert result.breakdown["sla_compliance"] == pytest.approx(0.10, abs=0.01)
|
| 416 |
+
assert result.breakdown["monitoring_patience"] == pytest.approx(0.05, abs=0.01)
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
class TestHardTaskEdgeCases:
|
| 420 |
+
"""Edge case tests for the hard task mechanics."""
|
| 421 |
+
|
| 422 |
+
def test_permanent_failure_retry_rejected(self) -> None:
|
| 423 |
+
"""Retrying alpha on enrich_logs after permanent failure should be rejected."""
|
| 424 |
+
env, _ = _make_env("hard")
|
| 425 |
+
# S0: triage
|
| 426 |
+
_delegate(env, "alert_triage", "triage_analyst")
|
| 427 |
+
# S1: alpha on enrich_logs (will fail permanently)
|
| 428 |
+
_delegate(env, "enrich_logs", "investigator_alpha")
|
| 429 |
+
# S2: need another action for alpha to finish (speed=2)
|
| 430 |
+
_wait(env)
|
| 431 |
+
# Now enrich_logs is failed (permanent)
|
| 432 |
+
failed = [s for s in env.state.subtask_statuses
|
| 433 |
+
if env.state.subtask_statuses[s] == "failed"]
|
| 434 |
+
assert "enrich_logs" in failed
|
| 435 |
+
# Retry with alpha should be rejected (permanent failure)
|
| 436 |
+
obs = _retry(env, "enrich_logs", "investigator_alpha")
|
| 437 |
+
assert len(obs.errors) == 1
|
| 438 |
+
assert "permanent" in obs.errors[0].lower()
|
| 439 |
+
assert obs.reward < 0 # penalty applied
|
| 440 |
+
|
| 441 |
+
def test_monitoring_patience_failure(self) -> None:
|
| 442 |
+
"""Synthesizing immediately after all complete loses patience score."""
|
| 443 |
+
from server.graders import grade_hard
|
| 444 |
+
|
| 445 |
+
env, _ = _make_env("hard")
|
| 446 |
+
# Run the known-good sequence up to all subtasks complete (step 10)
|
| 447 |
+
_delegate(env, "alert_triage", "triage_analyst")
|
| 448 |
+
_delegate(env, "enrich_logs", "investigator_alpha")
|
| 449 |
+
_delegate(env, "check_dashboards", "monitor")
|
| 450 |
+
_retry(env, "enrich_logs", "investigator_beta")
|
| 451 |
+
_delegate(env, "check_dependencies", "investigator_alpha")
|
| 452 |
+
_delegate(env, "notify_stakeholders", "communicator")
|
| 453 |
+
_delegate(env, "root_cause_analysis", "senior_engineer")
|
| 454 |
+
_delegate(env, "deploy_hotfix", "deployer")
|
| 455 |
+
_delegate(env, "update_status_page", "communicator")
|
| 456 |
+
_delegate(env, "validate_fix", "senior_engineer")
|
| 457 |
+
_delegate(env, "monitor_recovery", "monitor")
|
| 458 |
+
# Synthesize immediately — NO patience waits
|
| 459 |
+
obs = _synthesize(env)
|
| 460 |
+
assert obs.done is True
|
| 461 |
+
|
| 462 |
+
log = _episode_store["hard"]
|
| 463 |
+
result = grade_hard(log)
|
| 464 |
+
assert result.breakdown["monitoring_patience"] == 0.0
|
| 465 |
+
|
| 466 |
+
def test_bad_episode_near_zero_score(self) -> None:
|
| 467 |
+
"""Just waiting until timeout should score near 0."""
|
| 468 |
+
from server.graders import grade_hard
|
| 469 |
+
|
| 470 |
+
env, _ = _make_env("hard")
|
| 471 |
+
for _ in range(22):
|
| 472 |
+
_wait(env)
|
| 473 |
+
log = _episode_store["hard"]
|
| 474 |
+
result = grade_hard(log)
|
| 475 |
+
# Even doing nothing scores 0.3: error_classification(0.1) +
|
| 476 |
+
# capacity_discipline(0.1) + cost_efficiency(0.1) from "no harm done"
|
| 477 |
+
assert result.score <= 0.35
|
| 478 |
+
assert result.breakdown["completion"] == 0.0
|
| 479 |
+
assert result.breakdown["recovery"] == 0.0
|
| 480 |
+
assert result.breakdown["sla_compliance"] == 0.0
|
| 481 |
+
# All 9 keys should still be present
|
| 482 |
+
assert len(result.breakdown) == 9
|
| 483 |
+
|
| 484 |
+
def test_sla_penalty_when_delayed(self) -> None:
|
| 485 |
+
"""Delaying root_cause past step 10 should incur SLA penalties."""
|
| 486 |
+
env, _ = _make_env("hard")
|
| 487 |
+
_delegate(env, "alert_triage", "triage_analyst")
|
| 488 |
+
# Wait until step 11+ without completing root_cause
|
| 489 |
+
for _ in range(11):
|
| 490 |
+
_wait(env)
|
| 491 |
+
# By now, step_count=12, root_cause not done → SLA penalties accrued
|
| 492 |
+
state = env.state
|
| 493 |
+
assert state.subtask_statuses["root_cause_analysis"] != "completed"
|
| 494 |
+
# Total reward should be negative due to SLA + unnecessary_wait penalties
|
| 495 |
+
assert env._total_reward < 0
|
tests/test_task_registry.py
CHANGED
|
@@ -39,18 +39,18 @@ class TestTaskRegistry:
|
|
| 39 |
|
| 40 |
def test_list_tasks(self) -> None:
|
| 41 |
tasks = list_tasks()
|
| 42 |
-
assert len(tasks) =
|
| 43 |
ids = {t.task_id for t in tasks}
|
| 44 |
-
assert
|
| 45 |
|
| 46 |
-
@pytest.mark.parametrize("task_id", ["easy", "medium", "hard"])
|
| 47 |
def test_dag_is_valid(self, task_id: str) -> None:
|
| 48 |
"""Every task's subtask definitions form a valid DAG (no cycles)."""
|
| 49 |
config = get_task(task_id)
|
| 50 |
dag = DAGExecutor(config.subtask_definitions)
|
| 51 |
assert dag is not None
|
| 52 |
|
| 53 |
-
@pytest.mark.parametrize("task_id", ["easy", "medium", "hard"])
|
| 54 |
def test_all_subtask_types_have_capable_agent(self, task_id: str) -> None:
|
| 55 |
"""Every subtask type has at least one agent that can handle it."""
|
| 56 |
config = get_task(task_id)
|
|
|
|
| 39 |
|
| 40 |
def test_list_tasks(self) -> None:
|
| 41 |
tasks = list_tasks()
|
| 42 |
+
assert len(tasks) >= 3
|
| 43 |
ids = {t.task_id for t in tasks}
|
| 44 |
+
assert {"easy", "medium", "hard"}.issubset(ids)
|
| 45 |
|
| 46 |
+
@pytest.mark.parametrize("task_id", ["easy", "medium", "hard", "expert"])
|
| 47 |
def test_dag_is_valid(self, task_id: str) -> None:
|
| 48 |
"""Every task's subtask definitions form a valid DAG (no cycles)."""
|
| 49 |
config = get_task(task_id)
|
| 50 |
dag = DAGExecutor(config.subtask_definitions)
|
| 51 |
assert dag is not None
|
| 52 |
|
| 53 |
+
@pytest.mark.parametrize("task_id", ["easy", "medium", "hard", "expert"])
|
| 54 |
def test_all_subtask_types_have_capable_agent(self, task_id: str) -> None:
|
| 55 |
"""Every subtask type has at least one agent that can handle it."""
|
| 56 |
config = get_task(task_id)
|