85742ab165
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
363 lines
12 KiB
Python
363 lines
12 KiB
Python
# 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())
|