forked from microsoft/agent-lightning
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathroom_selector.py
More file actions
362 lines (305 loc) · 12.1 KB
/
Copy pathroom_selector.py
File metadata and controls
362 lines (305 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import traceback
from typing import List, Optional, Tuple, TypedDict, cast
from openai import OpenAI
from openai.types.chat import (
ChatCompletionAssistantMessageParam,
ChatCompletionMessageFunctionToolCallParam,
ChatCompletionMessageParam,
ChatCompletionToolMessageParam,
ChatCompletionToolParam,
)
from pydantic import BaseModel, Field
from rich.console import Console
from agentlightning.adapter import TraceToMessages
from agentlightning.litagent import rollout
from agentlightning.reward import find_final_reward
from agentlightning.runner import LitAgentRunner
from agentlightning.store import InMemoryLightningStore
from agentlightning.tracer.agentops import AgentOpsTracer
from agentlightning.types import Dataset, PromptTemplate
console = Console()
class JudgeResponse(BaseModel):
reason: str = Field(description="The reason for the score. No more than 100 characters.")
score: float = Field(description="The score for the match on a 0-1 scale. Be critical.")
class Room(TypedDict):
id: str
capacity: int
equipment: List[str]
accessible: bool
distance_m: int
booked: List[Tuple[str, str, int]]
class RoomStatus(Room):
free: bool
class AvailableRooms(TypedDict):
rooms: List[RoomStatus]
class RoomRequirement(TypedDict):
date: str
time: str
duration_min: int
attendees: int
needs: List[str]
accessible_required: bool
class RoomSelectionTask(TypedDict):
id: str
task_input: RoomRequirement
expected_choice: str
TOOL_DEFINITIONS: List[ChatCompletionToolParam] = [
{
"type": "function",
"function": {
"name": "get_rooms_and_availability",
"description": "Return meeting rooms with capacity, equipment, accessibility, distance, and booked time slots.",
"parameters": {
"type": "object",
"properties": {
"date": {"type": "string", "description": "YYYY-MM-DD"},
"time": {"type": "string", "description": "HH:MM 24h local"},
"duration_min": {"type": "integer", "description": "Meeting duration minutes"},
},
"required": ["date", "time", "duration_min"],
},
},
},
]
def prompt_template_baseline() -> PromptTemplate:
return PromptTemplate(
template="Find a room on {date} at {time} for {duration_min} minutes, {attendees} attendees. Needs: {needs}. Accessible required: {accessible_required}",
engine="f-string",
)
def room_selection_grader(client: OpenAI, final_message: Optional[str], expected_choice: str) -> float:
judge_prompt = (
f"You are a strict grader of exact room choice."
f"Task output:\n{final_message}\n\n"
f"Task expected answer:\n{expected_choice}\n\n"
f"Score the match on a 0-1 scale. Be critical.\n"
f"Bear in mind that the score can be partially correct (between 0 and 1)."
)
judge = client.chat.completions.parse(
model="gpt-4.1-mini",
messages=[
{"role": "user", "content": judge_prompt},
],
response_format=JudgeResponse,
temperature=0.0,
)
judge_result = judge.choices[0].message.content
console.print(f"[bold yellow]=== Judge ===[/bold yellow]")
console.print(judge_result)
judge_result_parsed = JudgeResponse.model_validate_json(judge_result) # type: ignore
console.print(f"[bold yellow]=== Judge Score ===[/bold yellow]")
console.print(judge_result_parsed.score)
return judge_result_parsed.score
@rollout
def room_selector(task: RoomSelectionTask, prompt_template: PromptTemplate) -> float:
"""An agent to select a room based on the given requirements.
Oracle System Prompt (works with 100% accuracy with gpt-5 mini low reasoning effort):
You are a scheduling assistant.
Hard constraints: free for slot, capacity >= attendees, includes all required equipment,
accessible==True if requested.
Tie-break scoring (lower is better):
1) capacity_slack = capacity - attendees (minimize)
2) extra_equipment = provided_equipment_count - required_equipment_count (minimize)
3) distance_m (minimize)
4) fewer total booked blocks that day (minimize)
Return No Room if no room is found that satisfies the constraints.
Return strictly:
final_choice: <ROOM_ID>
reason: <one line stating the decisive criteria>
Oracle User Prompt Template:
Find a room on {task_input['date']} at {task_input['time']} for {task_input['duration_min']} minutes,
{task_input['attendees']} attendees. Needs: {', '.join(task_input['needs']) or 'none'}.
Accessible required: {task_input['accessible_required']}
The current implementation greatly simply the oracle prompt and prompt template is provided by a parameter.
The prompt template should be tuned by Agent-lightning's APO algorithm.
It also should work with a very small model like gpt-4.1-nano.
"""
client = OpenAI()
model = "gpt-4.1-nano"
user_message = prompt_template.format(**task["task_input"])
messages: List[ChatCompletionMessageParam] = [
{"role": "system", "content": "You are a scheduling assistant."},
{
"role": "user",
"content": user_message,
},
]
console.print(f"[bold yellow]=== User Message ===[/bold yellow]")
console.print(user_message)
resp = client.chat.completions.create(
model=model,
messages=messages,
tools=TOOL_DEFINITIONS,
tool_choice="auto",
# Minimize the randomness
temperature=0.0,
# Uncomment for gpt-5
# reasoning_effort="low",
)
console.print(f"[bold yellow]=== Assistant Message ===[/bold yellow]")
console.print(resp.choices[0].message)
# Parse and process the tool calls
tool_calls = resp.choices[0].message.tool_calls
if tool_calls:
tool_call_params: List[ChatCompletionMessageFunctionToolCallParam] = []
tool_results: List[ChatCompletionToolMessageParam] = []
for tc in tool_calls:
if tc.type != "function":
raise ValueError(f"Tool call is not a function: {tc}")
if tc.function.name != "get_rooms_and_availability":
raise ValueError(f"Tool call is not get_rooms_and_availability: {tc}")
tool_call_params.append(
ChatCompletionMessageFunctionToolCallParam(
id=tc.id,
type="function",
function={"name": tc.function.name, "arguments": tc.function.arguments},
)
)
args = json.loads(tc.function.arguments)
try:
tool_output = get_rooms_and_availability(args["date"], args["time"], args["duration_min"])
except Exception as e:
tool_output = {
"error": str(e),
"traceback": traceback.format_exc(),
}
console.print(f"[bold yellow]=== Tool Message ===[/bold yellow]")
console.print(tool_output)
tool_results.append(
ChatCompletionToolMessageParam(
role="tool",
tool_call_id=tc.id,
content=json.dumps(tool_output),
)
)
# Update the messages for the next call
messages.append(
ChatCompletionAssistantMessageParam(
role="assistant",
content=resp.choices[0].message.content,
tool_calls=tool_call_params,
)
)
messages.extend(tool_results)
next_resp = client.chat.completions.create(
model=model,
messages=messages,
# Minimize the randomness
temperature=0.0,
)
console.print(f"[bold yellow]=== Final Assistant Message ===[/bold yellow]")
console.print(next_resp.choices[0].message.content)
final_message = next_resp.choices[0].message.content
else:
final_message = resp.choices[0].message.content
return room_selection_grader(client, final_message, task["expected_choice"])
# Local tool database (there might be multiple plausible fits)
ROOMS: List[Room] = [
{
"id": "Orion",
"capacity": 4,
"equipment": ["tv", "whiteboard"],
"accessible": True,
"distance_m": 12,
"booked": [("2025-10-13", "10:00", 60), ("2025-10-13", "15:00", 30)],
},
{
"id": "Lyra",
"capacity": 10,
"equipment": ["projector", "whiteboard", "confphone"],
"accessible": True,
"distance_m": 30,
"booked": [("2025-10-13", "09:30", 30), ("2025-10-13", "11:00", 60)],
},
{
"id": "Vega",
"capacity": 6,
"equipment": ["tv"],
"accessible": False,
"distance_m": 22,
"booked": [("2025-10-13", "14:00", 60)],
},
{
"id": "Nova",
"capacity": 12,
"equipment": ["ledwall", "whiteboard", "confphone"],
"accessible": True,
"distance_m": 45,
"booked": [],
},
{
"id": "Quark",
"capacity": 8,
"equipment": ["projector", "whiteboard"],
"accessible": False,
"distance_m": 18,
"booked": [("2025-10-13", "10:30", 30)],
},
# Two extra to create harder ties
{
"id": "Atlas",
"capacity": 6,
"equipment": ["projector", "whiteboard"],
"accessible": True,
"distance_m": 10,
"booked": [("2025-10-13", "09:00", 30), ("2025-10-13", "13:30", 30)],
},
{
"id": "Pulse",
"capacity": 8,
"equipment": ["tv", "whiteboard", "confphone"],
"accessible": True,
"distance_m": 8,
"booked": [("2025-10-13", "16:30", 30)],
},
]
def overlaps(start: str, dur: int, other_start: str, other_dur: int) -> bool:
def tmin(t: str):
return int(t[:2]) * 60 + int(t[3:])
a0, a1 = tmin(start), tmin(start) + dur
b0, b1 = tmin(other_start), tmin(other_start) + other_dur
return max(a0, b0) < min(a1, b1)
def get_rooms_and_availability(date: str, time_str: str, duration_min: int) -> AvailableRooms:
avail: List[RoomStatus] = []
for r in ROOMS:
free = all(
not (b_date == date and overlaps(time_str, duration_min, b_time, b_dur))
for (b_date, b_time, b_dur) in r["booked"]
)
item: RoomStatus = {
**r,
"free": free,
}
avail.append(item)
return {"rooms": avail}
def load_room_tasks() -> Dataset[RoomSelectionTask]:
tasks: List[RoomSelectionTask] = []
for line in open("room_tasks.jsonl"):
task = json.loads(line)
tasks.append(RoomSelectionTask(**task))
return cast(Dataset[RoomSelectionTask], tasks)
async def debug_room_selector(limit: int = 1):
# Prepare all the components to run the agent
runner = LitAgentRunner[RoomSelectionTask](AgentOpsTracer())
store = InMemoryLightningStore()
prompt_template = prompt_template_baseline()
tasks = load_room_tasks()
with runner.run_context(agent=room_selector, store=store):
for task in tasks:
console.print("[bold green]=== Task ===[/bold green]", task, sep="\n")
# Run the agent
rollout = await runner.step(task, resources={"main_prompt": prompt_template})
# Get the spans and convert them to messages
# Useful for debugging and analysis
spans = await store.query_spans(rollout.rollout_id)
adapter = TraceToMessages()
messages = adapter.adapt(spans)
for message_idx, message in enumerate(messages):
console.print(f"[bold purple]=== Postmortem Message #{message_idx} ===[/bold purple]")
console.print(json.dumps(message))
reward = find_final_reward(spans)
console.print("[bold purple]=== Postmortem Reward ===[/bold purple]", reward, sep="\n")
if __name__ == "__main__":
asyncio.run(debug_room_selector())