kartikmandar commited on
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 CHANGED
@@ -13,243 +13,163 @@ tags:
13
 
14
  # Workflow Orchestrator Environment
15
 
16
- A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns.
17
-
18
- ## Quick Start
19
-
20
- The simplest way to use the Workflow Orchestrator environment is through the `WorkflowOrchestratorEnv` class:
21
-
22
- ```python
23
- from workflow_orchestrator import WorkflowOrchestratorAction, WorkflowOrchestratorEnv
24
-
25
- try:
26
- # Create environment from Docker image
27
- workflow_orchestratorenv = WorkflowOrchestratorEnv.from_docker_image("workflow_orchestrator-env:latest")
28
-
29
- # Reset
30
- result = workflow_orchestratorenv.reset()
31
- print(f"Reset: {result.observation.echoed_message}")
32
-
33
- # Send multiple messages
34
- messages = ["Hello, World!", "Testing echo", "Final message"]
35
-
36
- for msg in messages:
37
- result = workflow_orchestratorenv.step(WorkflowOrchestratorAction(message=msg))
38
- print(f"Sent: '{msg}'")
39
- print(f" → Echoed: '{result.observation.echoed_message}'")
40
- print(f" → Length: {result.observation.message_length}")
41
- print(f" → Reward: {result.reward}")
42
-
43
- finally:
44
- # Always clean up
45
- workflow_orchestratorenv.close()
46
- ```
47
-
48
- That's it! The `WorkflowOrchestratorEnv.from_docker_image()` method handles:
49
- - Starting the Docker container
50
- - Waiting for the server to be ready
51
- - Connecting to the environment
52
- - Container cleanup when you call `close()`
53
-
54
- ## Building the Docker Image
55
-
56
- Before using the environment, you need to build the Docker image:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  ```bash
59
- # From project root
60
- docker build -t workflow_orchestrator-env:latest -f server/Dockerfile .
 
61
  ```
62
 
63
- ## Deploying to Hugging Face Spaces
64
-
65
- You can easily deploy your OpenEnv environment to Hugging Face Spaces using the `openenv push` command:
66
 
67
  ```bash
68
- # From the environment directory (where openenv.yaml is located)
69
- openenv push
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
- The client uses WebSocket connections for:
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
- # From the server directory
221
- python3 server/workflow_orchestrator_environment.py
 
 
222
  ```
223
 
224
- This verifies that:
225
- - Environment resets correctly
226
- - Step executes actions properly
227
- - State tracking works
228
- - Rewards are calculated correctly
229
-
230
- ### Running Locally
231
 
232
- Run the server locally for development:
233
-
234
- ```bash
235
- uvicorn server.app:app --reload
236
- ```
 
 
 
 
 
 
237
 
238
  ## Project Structure
239
 
240
  ```
241
  workflow_orchestrator/
242
- ├── .dockerignore # Docker build exclusions
243
- ├── __init__.py # Module exports
244
- ├── README.md # This file
245
- ├── openenv.yaml # OpenEnv manifest
246
- ├── pyproject.toml # Project metadata and dependencies
247
- ├── uv.lock # Locked dependencies (generated)
248
- ├── client.py # WorkflowOrchestratorEnv client
249
- ├── models.py # Action and Observation models
250
- ── server/
251
- ├── __init__.py # Server module exports
252
- ├── workflow_orchestrator_environment.py # Core environment logic
253
- ├── app.py # FastAPI application (HTTP + WebSocket endpoints)
254
- ── Dockerfile # Container image definition
 
 
 
 
 
 
 
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": null, "medium": null, "hard": null}
 
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
- from .models import OrchestratorAction, OrchestratorObservation, OrchestratorState
 
 
 
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 test_get_tasks_returns_three(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,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 ids == {"easy", "medium", "hard"}
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) == 3
43
  ids = {t.task_id for t in tasks}
44
- assert ids == {"easy", "medium", "hard"}
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)