Files
wehub-resource-sync b957a53def
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:21:23 +08:00

252 lines
11 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
import logging
import math
import time
from pydantic import BaseModel
class ResourceConstraintUnit(Enum):
"""Choose the unit of the resource constraint.
Seconds and Minutes are real-time and will be impacted by the latency of the model."""
SECONDS = "seconds"
MINUTES = "minutes"
TURNS = "turns"
class ResourceConstraintMode(Enum):
"""Choose how the agent should use the resource.
Maximum: is an upper bound, i.e. the agent can end the conversation before the resource is exhausted
Exact: the agent should aim to use exactly the given amount of the resource"""
MAXIMUM = "maximum"
EXACT = "exact"
class ResourceConstraint(BaseModel):
"""A structured representation of the resource constraint for the GuidedConversation agent.
Args:
quantity (float | int): The quantity of the resource constraint.
unit (ResourceConstraintUnit): The unit of the resource constraint.
mode (ResourceConstraintMode): The mode of the resource constraint.
"""
quantity: float | int
unit: ResourceConstraintUnit
mode: ResourceConstraintMode
class Config:
arbitrary_types_allowed = True
def format_resource(quantity: float, unit: ResourceConstraintUnit) -> str:
"""Get formatted string for a given quantity and unit (e.g. 1 second, 20 seconds)"""
if unit != ResourceConstraintUnit.TURNS:
quantity = round(quantity, 1)
unit = unit.value
return f"{quantity} {unit[:-1] if quantity == 1 else unit}"
class GCResource:
"""Resource constraints for the GuidedConversation agent. This class is used to keep track of the resource
constraints. If resource_constraint is None, then the agent can continue indefinitely. This also means
that no agenda will be created for the conversation.
Args:
resource_constraint (ResourceConstraint | None): The resource constraint for the conversation.
initial_seconds_per_turn (int): The initial number of seconds per turn. Defaults to 120 seconds.
"""
def __init__(
self,
resource_constraint: ResourceConstraint | None,
initial_seconds_per_turn: int = 120,
):
logger = logging.getLogger(__name__)
self.logger = logger
self.resource_constraint: ResourceConstraint | None = resource_constraint
self.initial_seconds_per_turn: int = initial_seconds_per_turn
self.turn_number: int = 0
self.remaining_units: float | None = None
self.elapsed_units: float | None = None
if resource_constraint is not None:
self.elapsed_units = 0
self.remaining_units = resource_constraint.quantity
def start_resource(self) -> None:
"""To be called at the start of a conversation turn"""
if self.resource_constraint is not None and (
self.resource_constraint.unit == ResourceConstraintUnit.SECONDS
or self.resource_constraint.unit == ResourceConstraintUnit.MINUTES
):
self.start_time = time.time()
def increment_resource(self) -> None:
"""Increment the resource counter by one turn."""
if self.resource_constraint is not None:
if self.resource_constraint.unit == ResourceConstraintUnit.SECONDS:
self.elapsed_units += time.time() - self.start_time
self.remaining_units = self.resource_constraint.quantity - self.elapsed_units
elif self.resource_constraint.unit == ResourceConstraintUnit.MINUTES:
self.elapsed_units += (time.time() - self.start_time) / 60
self.remaining_units = self.resource_constraint.quantity - self.elapsed_units
elif self.resource_constraint.unit == ResourceConstraintUnit.TURNS:
self.elapsed_units += 1
self.remaining_units -= 1
self.turn_number += 1
def get_resource_mode(self) -> ResourceConstraintMode:
"""Get the mode of the resource constraint.
Returns:
ResourceConstraintMode | None: The mode of the resource constraint, or None if there is no
resource constraint.
"""
return self.resource_constraint.mode if self.resource_constraint is not None else None
def get_elapsed_turns(self, formatted_repr: bool = False) -> str | int:
"""Get the number of elapsed turns.
Args:
formatted_repr (bool): If true, return a formatted string representation of the elapsed turns.
If false, return an integer. Defaults to False.
Returns:
str | int: The description/number of elapsed turns.
"""
if formatted_repr:
return format_resource(self.turn_number, ResourceConstraintUnit.TURNS)
else:
return self.turn_number
def get_remaining_turns(self, formatted_repr: bool = False) -> str | int:
"""Get the number of remaining turns.
Args:
formatted_repr (bool): If true, return a formatted string representation of the remaining turns.
Returns:
str | int: The description/number of remaining turns.
"""
if formatted_repr:
return format_resource(self.estimate_remaining_turns(), ResourceConstraintUnit.TURNS)
else:
return self.estimate_remaining_turns()
def estimate_remaining_turns(self) -> int:
"""Estimate the remaining turns based on the resource constraint, thereby translating certain
resource units (e.g. seconds, minutes) into turns.
Returns:
int: The estimated number of remaining turns.
"""
if self.resource_constraint is not None:
if (
self.resource_constraint.unit == ResourceConstraintUnit.SECONDS
or self.resource_constraint.unit == ResourceConstraintUnit.MINUTES
):
elapsed_turns = self.turn_number
# TODO: This can likely be simplified
if self.resource_constraint.unit == ResourceConstraintUnit.MINUTES:
time_per_turn = (
self.initial_seconds_per_turn
if elapsed_turns == 0
else (self.elapsed_units * 60) / elapsed_turns
)
time_per_turn /= 60
else:
time_per_turn = (
self.initial_seconds_per_turn if elapsed_turns == 0 else self.elapsed_units / elapsed_turns
)
remaining_turns = self.remaining_units / time_per_turn
# Round down, unless it's less than 1, in which case round up
remaining_turns = math.ceil(remaining_turns) if remaining_turns < 1 else math.floor(remaining_turns)
return remaining_turns
elif self.resource_constraint.unit == ResourceConstraintUnit.TURNS:
return self.resource_constraint.quantity - self.turn_number
else:
self.logger.error(
"Resource constraint is not set, so turns cannot be estimated using function estimate_remaining_turns"
)
raise ValueError(
"Resource constraint is not set. Do not try to call this method without a resource constraint."
)
def get_resource_instructions(self) -> tuple[str, str]:
"""Get the resource instructions for the conversation.
Assumes we're always using turns as the resource unit.
Returns:
str: the resource instructions
"""
if self.resource_constraint is None:
return ""
formatted_elapsed_resource = format_resource(self.elapsed_units, ResourceConstraintUnit.TURNS)
formatted_remaining_resource = format_resource(self.remaining_units, ResourceConstraintUnit.TURNS)
# if the resource quantity is anything other than 1, the resource unit should be plural (e.g. "minutes" instead of "minute")
is_plural_elapsed = self.elapsed_units != 1
is_plural_remaining = self.remaining_units != 1
if self.elapsed_units > 0:
resource_instructions = f"So far, {formatted_elapsed_resource} {'have' if is_plural_elapsed else 'has'} elapsed since the conversation began. "
else:
resource_instructions = ""
if self.resource_constraint.mode == ResourceConstraintMode.EXACT:
exact_mode_instructions = f"""There {"are" if is_plural_remaining else "is"} {formatted_remaining_resource} remaining (including this one) - the conversation will automatically terminate when 0 turns are left. \
You should continue the conversation until it is automatically terminated. This means you should NOT preemptively end the conversation, \
either explicitly (by selecting the "End conversation" action) or implicitly (e.g. by telling the user that you have all required information and they should wait for the next step). \
Your goal is not to maximize efficiency (i.e. complete the artifact as quickly as possible then end the conversation), but rather to make the best use of ALL remaining turns available to you"""
if is_plural_remaining:
resource_instructions += f"""{exact_mode_instructions}. This will require you to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier, \
or where you've rushed through the conversation and all fields are completed but there are still many turns left."""
# special instruction for the final turn (i.e. 1 remaining) in exact mode
else:
resource_instructions += f"""{exact_mode_instructions}, including this one. Therefore, you should use this turn to ask for any remaining information needed to complete the artifact, \
or, if the artifact is already completed, continue to broaden/deepen the discussion in a way that's directly relevant to the artifact. Do NOT indicate to the user that the conversation is ending."""
elif self.resource_constraint.mode == ResourceConstraintMode.MAXIMUM:
resource_instructions += f"""You have a maximum of {formatted_remaining_resource} (including this one) left to complete the conversation. \
You can decide to terminate the conversation at any point (including now), otherwise the conversation will automatically terminate when 0 turns are left. \
You will need to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier."""
else:
self.logger.error("Invalid resource mode provided.")
return resource_instructions
def to_json(self) -> dict:
return {
"turn_number": self.turn_number,
"remaining_units": self.remaining_units,
"elapsed_units": self.elapsed_units,
}
@classmethod
def from_json(
cls,
json_data: dict,
) -> "GCResource":
gc_resource = cls(
resource_constraint=None,
initial_seconds_per_turn=120,
)
gc_resource.turn_number = json_data["turn_number"]
gc_resource.remaining_units = json_data["remaining_units"]
gc_resource.elapsed_units = json_data["elapsed_units"]
return gc_resource