toolforge-env / server /toolforge_env_environment.py
Hugging-coder14's picture
Upload folder using huggingface_hub
2b3313a verified
Raw
History Blame Contribute Delete
22.8 kB
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""
Toolforge Env Environment Implementation.
A simple test environment that echoes back messages sent to it.
Perfect for testing HTTP server infrastructure.
"""
import logging
from uuid import uuid4
from openenv.core.env_server.interfaces import Environment
from openenv.core.env_server.types import EnvironmentMetadata, State
from typing import Any, Dict, List, Optional
try:
from .inputs.base import InputProvider
from .inputs.simulated.task_selector import TaskSelector
from .inputs.factory import create_input_provider
except ImportError:
from server.inputs.base import InputProvider
from server.inputs.simulated.task_selector import TaskSelector
from server.inputs.factory import create_input_provider
try:
from ..models import (
Tool,
ToolCall,
ToolforgeAction,
ToolforgeObservation,
ToolForgeState,
Task,
EpisodeGradingState,
)
except ImportError:
from models import (
Tool,
ToolCall,
ToolforgeAction,
ToolforgeObservation,
ToolForgeState,
Task,
EpisodeGradingState,
)
try:
from server.tools import build_atomic_tools
from server.evaluation_pipeline import run_evaluation_pipeline
from server.plan_evaluator import update_sequence_counts
except ImportError:
from .tools import build_atomic_tools
from .evaluation_pipeline import run_evaluation_pipeline
from .plan_evaluator import update_sequence_counts
logger = logging.getLogger(__name__)
class ToolforgeEnvironment(Environment):
"""
A simple echo environment that echoes back messages.
This environment is designed for testing the HTTP server infrastructure.
It maintains minimal state and simply echoes back whatever message it receives.
Example:
>>> env = ToolforgeEnvironment()
>>> obs = env.reset()
>>> print(obs.echoed_message) # "Toolforge Env environment ready!"
>>>
>>> obs = env.step(ToolforgeAction(message="Hello"))
>>> print(obs.echoed_message) # "Hello"
>>> print(obs.message_length) # 5
"""
# Enable concurrent WebSocket sessions.
# Set to True if your environment isolates state between instances.
# When True, multiple WebSocket clients can connect simultaneously, each
# getting their own environment instance (when using factory mode in app.py).
SUPPORTS_CONCURRENT_SESSIONS: bool = True
# Hard guardrail to ensure episodes terminate deterministically.
MAX_EPISODE_STEPS: int = 100
def __init__(self):
"""Initialize the toolforge_env environment."""
super().__init__(transform=None, rubric=None)
self._state = self._create_default_state()
self._reset_count = 0
self._task_selector = TaskSelector()
self._input_provider_factory = create_input_provider
self._input_provider: Optional[InputProvider] = None
self._last_approval: Optional[bool] = None
# persistent config
self.mode = None
self.difficulty: str = "easy"
self.initialized = False
def _create_default_state(self) -> ToolForgeState:
"""
Create a default ToolForgeState with basic parameters.
Returns:
ToolForgeState with default values
"""
default_task = Task(
id="default-task",
prompt="Default task",
difficulty="easy",
required_slots=[],
baseline_token_cost=0,
)
return ToolForgeState(
episode_id=str(uuid4()),
step_count=0,
current_task=default_task,
task_queue=[],
completed_tasks=[],
available_tools=[],
accepted_macros=[],
rejected_macro_count=0,
call_history=[],
tokens_used=0,
done=False,
)
def _create_default_observation(self) -> ToolforgeObservation:
"""
Create a default ToolforgeObservation with basic parameters.
Returns:
ToolforgeObservation with default values
"""
return ToolforgeObservation(
current_task=self._state.current_task,
available_tools=self._available_tools_to_prompt_specs(self._state.available_tools),
)
def _tool_to_prompt_spec(self, tool: Tool) -> Dict[str, Any]:
"""Convert a Tool model into a plain prompt-friendly dictionary."""
return {
"name": tool.name,
"description": tool.description,
"is_macro": tool.is_macro,
"steps": tool.steps or [],
}
def _available_tools_to_prompt_specs(self, tools: List[Tool]) -> List[Dict[str, Any]]:
"""Convert available tools to plain dictionaries ready for prompt injection.
Macros are listed first, followed by atomic tools.
"""
sorted_tools = sorted(tools, key=lambda t: not t.is_macro)
return [self._tool_to_prompt_spec(tool) for tool in sorted_tools]
def reset(
self,
seed: Optional[int] = None,
episode_id: Optional[str] = None,
task_id: Optional[str] = None,
**kwargs
) -> ToolforgeObservation:
"""
Reset the environment.
Returns:
ToolforgeObservation with a ready message
"""
ep_id = episode_id if episode_id is not None else str(uuid4())
# old state preservation
prev_accepted_macros = self._state.accepted_macros if hasattr(self, '_state') else []
prev_macro_defs = self._state.macro_definitions if hasattr(self, '_state') else {}
prev_rejected_count = self._state.rejected_macro_count if hasattr(self, '_state') else 0
self._state = self._create_default_state()
self._state.episode_id = ep_id
self._reset_count += 1
resolved_task_id = task_id or kwargs.get("task_id", "easy")
# subsequent resets ignore incoming values
task_list = self._task_selector.next_task_list(resolved_task_id)
self._input_provider = self._input_provider_factory(task_list)
first_task = self._get_next_task_from_generator()
self._state.current_task = first_task
self._sync_task_queue_from_generator()
self._state.completed_tasks = []
self._state.available_tools = build_atomic_tools() + prev_accepted_macros
self._state.accepted_macros = prev_accepted_macros
self._state.rejected_macro_count = prev_rejected_count
self._state.call_history = []
self._state.tokens_used = 0
self._state.done = False
# Reset episode-level macro tracking state
self._state.sequence_counts = {}
self._state.macro_usage_counts = {}
self._state.macro_definitions = prev_macro_defs
self._last_approval = None
# Fresh episode-level grading accumulator
self._state.grading = EpisodeGradingState()
obs = self._create_default_observation()
obs.done = False
obs.reward = 0.0
return obs
def step(self, action: ToolforgeAction) -> ToolforgeObservation: # type: ignore[override]
"""
Execute a step in the environment by echoing the message.
Args:
action: ToolforgeAction containing the message to echo
Returns:
ToolforgeObservation with the echoed message and its length
"""
if self._is_done():
obs_terminal = self._create_default_observation()
obs_terminal.done = True
obs_terminal.reward = 0.0
obs_terminal.metadata = {
"summary": "Episode already completed.",
"terminal": True,
}
return obs_terminal
if not isinstance(action, ToolforgeAction):
logger.warning(
"Malformed action rejected. Expected ToolforgeAction, got %s",
type(action).__name__,
)
self._state.step_count += 1
self._last_approval = False
# Track malformed actions in grading state
self._state.grading.episode_steps += 1
self._state.grading.validation_failures += 1
progression = "advanced_to_next_task_malformed_action"
if self._state.step_count >= self.MAX_EPISODE_STEPS:
self._state.done = True
progression = "episode_terminated_max_steps"
else:
advanced = self._advance_to_next_task()
progression = "advanced_to_next_task" if advanced else "episode_completed"
obs_malformed = self._create_default_observation()
obs_malformed.done = self._is_done()
obs_malformed.reward = 0.0
obs_malformed.metadata = {
"summary": "Malformed action treated as failed attempt.",
"malformed_action": True,
"plan_accepted": False,
"task_prompt": self._state.current_task.prompt,
"task_level": self._state.current_task.difficulty,
"progression": progression,
}
return obs_malformed
self._state.step_count += 1
plan_accounting = self._analyze_plan(action.plan)
step_call_count = plan_accounting["step_call_count"]
unknown_tool_calls = plan_accounting["unknown_tool_calls"]
self._state.call_history.extend(action.plan)
self._state.tokens_used += step_call_count
available_tools_by_name = {
tool.name: tool for tool in self._state.available_tools
}
pipeline_result = run_evaluation_pipeline(
plan=action.plan,
task=self._state.current_task,
available_tools=available_tools_by_name,
accepted_macros=self._state.accepted_macros,
baseline_token_cost=self._state.current_task.baseline_token_cost,
sequence_counts=self._state.sequence_counts,
macro_definitions=self._state.macro_definitions,
macro_proposal=action.macro_proposal,
)
self._last_approval = bool(pipeline_result.passed_validation)
# Update sequence counts AFTER pipeline evaluation so recognition uses prior counts only
update_sequence_counts(action.plan, self._state.sequence_counts)
# Update macro usage counts for any macro tools used in this plan
macro_names = {m.name for m in self._state.accepted_macros}
for call in action.plan:
if call.tool_name in macro_names:
self._state.macro_usage_counts[call.tool_name] = (
self._state.macro_usage_counts.get(call.tool_name, 0) + 1
)
progression = "advanced_to_next_task"
if self._state.step_count >= self.MAX_EPISODE_STEPS:
self._state.done = True
progression = "episode_terminated_max_steps"
else:
advanced = self._advance_to_next_task()
progression = "advanced_to_next_task" if advanced else "episode_completed"
macro_result = self._process_macro_proposal(
action=action,
can_accept=bool(pipeline_result.passed_validation),
reject_reason="plan_not_accepted",
)
if macro_result["decision"] == "approved" and action.macro_proposal is not None:
self._state.available_tools.append(action.macro_proposal)
# Accumulate grading state from this step
self._update_grading_state(pipeline_result, macro_result, action)
# Fetch the scalar reward from the evaluation pipeline
reward = float(pipeline_result.reward)
print(self._state.current_task.prompt, reward, progression, macro_result)
return ToolforgeObservation(
current_task=self._state.current_task,
available_tools=self._available_tools_to_prompt_specs(self._state.available_tools),
done=self._is_done(),
reward=reward
)
def _advance_to_next_task(self) -> bool:
"""Advance from current task to the next queued task."""
completed_task_id = self._state.current_task.id
self._state.completed_tasks.append(self._state.current_task)
if self._input_provider is None or self._input_provider.is_done():
self._state.done = True
self._state.task_queue = []
logger.info(
"Episode complete. Final task '%s' finished; no tasks remain.",
completed_task_id,
)
return False
next_task = self._get_next_task_from_generator()
self._state.current_task = next_task
print("Prompt change")
self._sync_task_queue_from_generator()
logger.info(
"Task advanced from '%s' to '%s'. Remaining tasks=%d",
completed_task_id,
next_task.id,
len(self._state.task_queue),
)
logger.debug("Next task prompt: %s", next_task.prompt)
return True
def _update_grading_state(
self,
pipeline_result,
macro_result: Dict[str, Any],
action: ToolforgeAction,
) -> None:
"""Accumulate episode-level grading counters from a single step."""
g = self._state.grading
g.episode_steps += 1
# Validation failures
if not pipeline_result.passed_validation:
g.validation_failures += 1
# Harmful calls
if pipeline_result.step_harmful:
g.harmful_plan_count += 1
# Correct plans (full slot fill + valid)
if pipeline_result.step_task_complete and pipeline_result.passed_validation:
g.correct_plan_count += 1
# Efficiency tracking (only when slot_ratio == 1.0)
sr = pipeline_result.step_slot_ratio
if sr is not None and sr >= 1.0:
g.fully_correct_efficiency_opportunities += 1
g.sum_efficiency_score += (pipeline_result.step_efficiency_score or 0.0)
# Macro creation tracking
if macro_result["attempted"]:
g.macro_creation_attempts += 1
if macro_result["decision"] == "approved":
g.macro_creation_approved += 1
if pipeline_result.passed_validation:
g.macro_creation_correct += 1
g.macro_creation_bonus_total += (pipeline_result.step_macro_creation_bonus or 0.0)
elif macro_result["decision"] == "rejected":
g.macro_rejected_count += 1
# Macro usage tracking
macro_names = {m.name for m in self._state.accepted_macros}
if any(c.tool_name in macro_names for c in action.plan):
g.macro_usage_attempts += 1
if sr is not None and sr >= 0.65:
g.macro_usage_correct += 1
# Keep completed task count current
g.final_completed_tasks = len(self._state.completed_tasks)
def _analyze_plan(self, plan: List[ToolCall]) -> Dict[str, Any]:
"""Compute deterministic call accounting from server-side tool registry."""
available_tools_by_name = {
tool.name: tool for tool in self._state.available_tools
}
unknown_tool_calls: List[str] = []
for call in plan:
tool_def = available_tools_by_name.get(call.tool_name)
if tool_def is None:
unknown_tool_calls.append(call.tool_name)
return {
"step_call_count": len(plan),
"unknown_tool_calls": unknown_tool_calls,
}
def _process_macro_proposal(
self,
action: ToolforgeAction,
can_accept: bool,
reject_reason: str,
) -> Dict[str, Any]:
"""Evaluate and apply macro proposal lifecycle for this step."""
result: Dict[str, Any] = {
"attempted": False,
"decision": "none",
"name": None,
"reason": "no_macro_proposal",
}
has_macro_intent = (
action.action_type == "propose_plan_with_macro"
or action.macro_proposal is not None
)
if not has_macro_intent:
return result
result["attempted"] = True
if action.action_type != "propose_plan_with_macro":
return self._reject_macro(
result=result,
name=action.macro_proposal.name if action.macro_proposal else None,
reason="macro_proposal_requires_propose_plan_with_macro_action_type",
)
if action.macro_proposal is None:
return self._reject_macro(
result=result,
name=None,
reason="missing_macro_proposal_payload",
)
proposal = action.macro_proposal
macro_name = proposal.name.strip()
if not can_accept:
return self._reject_macro(
result=result,
name=macro_name,
reason=reject_reason,
)
if not macro_name:
return self._reject_macro(
result=result,
name=None,
reason="macro_name_cannot_be_empty",
)
if not proposal.steps:
return self._reject_macro(
result=result,
name=None,
reason="macro_steps_cannot_be_empty",
)
existing_names = {tool.name for tool in self._state.available_tools}
if macro_name in existing_names:
return self._reject_macro(
result=result,
name=macro_name,
reason="macro_name_already_exists",
)
if len(proposal.steps) < 2:
return self._reject_macro(
result=result,
name=macro_name,
reason="macro_requires_at_least_two_steps",
)
available_tools_by_name = {
tool.name: tool for tool in self._state.available_tools
}
missing_steps = [
call.tool_name
for call in proposal.steps
if call.tool_name not in available_tools_by_name
]
if missing_steps:
return self._reject_macro(
result=result,
name=macro_name,
reason=f"macro_contains_unknown_tools:{','.join(missing_steps)}",
)
if any(
available_tools_by_name[call.tool_name].is_macro
for call in proposal.steps
):
return self._reject_macro(
result=result,
name=macro_name,
reason="nested_macro_steps_not_supported",
)
composed_of: List[str] = [call.tool_name for call in proposal.steps]
macro_tool = Tool(
name=macro_name,
description=proposal.description.strip() or f"Macro: {' -> '.join(composed_of)}",
is_macro=True,
steps=proposal.steps,
)
self._state.accepted_macros.append(macro_tool)
self._state.available_tools.append(macro_tool)
# Store macro definition for sequence-based recognition tracking
self._state.macro_definitions[macro_name] = composed_of
result["decision"] = "approved"
result["name"] = macro_name
result["reason"] = "macro_registered"
logger.info(
"Macro approved: name='%s', steps=%s",
macro_name,
composed_of,
)
return result
def _reject_macro(
self,
result: Dict[str, Any],
name: Optional[str],
reason: str,
) -> Dict[str, Any]:
"""Record macro rejection and return standardized metadata payload."""
self._state.rejected_macro_count += 1
result["decision"] = "rejected"
result["name"] = name
result["reason"] = reason
logger.info("Macro rejected: name='%s', reason='%s'", name, reason)
return result
def _get_next_task_from_generator(self) -> Task:
"""Return next task from generator supporting get() or get_input()."""
print("Prompt change")
if self._input_provider is None:
raise RuntimeError("Task data generator not initialized. Call reset() first.")
return self._input_provider.get_input()
def _sync_task_queue_from_generator(self) -> None:
"""Best-effort sync of remaining tasks for state visibility."""
if self._input_provider is None:
self._state.task_queue = []
return
if hasattr(self._input_provider, "data") and hasattr(self._input_provider, "idx"):
data = getattr(self._input_provider, "data")
idx = getattr(self._input_provider, "idx")
self._state.task_queue = list(data[idx:])
return
# For generic providers without index access, keep queue opaque.
self._state.task_queue = []
def _is_done(self) -> bool:
"""Return True when the current episode should terminate."""
return bool(self._state.done) or self._state.step_count >= self.MAX_EPISODE_STEPS
@property
def state(self) -> State:
"""
Get the current environment state.
Returns:
Current State with episode_id and step_count
"""
return self._state
def get_metadata(self) -> EnvironmentMetadata:
"""Return descriptive metadata about this environment."""
return EnvironmentMetadata(
name="toolforge_env",
description=(
"A DevOps benchmark where an LLM agent learns to identify "
"recurring tool-call patterns and compose them into reusable "
"macro tools to minimise token consumption."
),
version="0.1.0",
)