Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
human_in_the_loop.py413 linesDownload Raw Back to middleware
1"""Human in the loop middleware."""2 3from typing import Any, Literal, Protocol4 5from langchain_core.messages import AIMessage, ToolCall, ToolMessage6from langgraph.runtime import Runtime7from langgraph.types import interrupt8from typing_extensions import NotRequired, TypedDict9 10from langchain.agents.middleware.types import (11    AgentMiddleware,12    AgentState,13    ContextT,14    ResponseT,15    StateT,16)17 18 19class Action(TypedDict):20    """Represents an action with a name and args."""21 22    name: str23    """The type or name of action being requested (e.g., `'add_numbers'`)."""24 25    args: dict[str, Any]26    """Key-value pairs of args needed for the action (e.g., `{"a": 1, "b": 2}`)."""27 28 29class ActionRequest(TypedDict):30    """Represents an action request with a name, args, and description."""31 32    name: str33    """The name of the action being requested."""34 35    args: dict[str, Any]36    """Key-value pairs of args needed for the action (e.g., `{"a": 1, "b": 2}`)."""37 38    description: NotRequired[str]39    """The description of the action to be reviewed."""40 41 42DecisionType = Literal["approve", "edit", "reject", "respond"]43 44 45class ReviewConfig(TypedDict):46    """Policy for reviewing a HITL request."""47 48    action_name: str49    """Name of the action associated with this review configuration."""50 51    allowed_decisions: list[DecisionType]52    """The decisions that are allowed for this request."""53 54    args_schema: NotRequired[dict[str, Any]]55    """JSON schema for the args associated with the action, if edits are allowed."""56 57 58class HITLRequest(TypedDict):59    """Request for human feedback on a sequence of actions requested by a model."""60 61    action_requests: list[ActionRequest]62    """A list of agent actions for human review."""63 64    review_configs: list[ReviewConfig]65    """Review configuration for all possible actions."""66 67 68class ApproveDecision(TypedDict):69    """Response when a human approves the action."""70 71    type: Literal["approve"]72    """The type of response when a human approves the action."""73 74 75class EditDecision(TypedDict):76    """Response when a human edits the action."""77 78    type: Literal["edit"]79    """The type of response when a human edits the action."""80 81    edited_action: Action82    """Edited action for the agent to perform.83 84    Ex: for a tool call, a human reviewer can edit the tool name and args.85    """86 87 88class RejectDecision(TypedDict):89    """Response when a human rejects the action."""90 91    type: Literal["reject"]92    """The type of response when a human rejects the action."""93 94    message: NotRequired[str]95    """The message sent to the model explaining why the action was rejected."""96 97 98class RespondDecision(TypedDict):99    """Response when a human answers on behalf of the tool, skipping execution.100 101    Used for "ask user" style tools whose real implementation is the human's102    response. The tool is not executed; instead, a synthetic `ToolMessage` with103    `status="success"` and the provided `message` is returned to the model.104    """105 106    type: Literal["respond"]107    """The type of response when a human responds on behalf of the tool."""108 109    message: str110    """Content of the synthetic `ToolMessage` returned to the model."""111 112 113Decision = ApproveDecision | EditDecision | RejectDecision | RespondDecision114 115 116class HITLResponse(TypedDict):117    """Response payload for a HITLRequest."""118 119    decisions: list[Decision]120    """The decisions made by the human."""121 122 123class _DescriptionFactory(Protocol):124    """Callable that generates a description for a tool call."""125 126    def __call__(127        self, tool_call: ToolCall, state: AgentState[Any], runtime: Runtime[ContextT]128    ) -> str:129        """Generate a description for a tool call."""130        ...131 132 133class InterruptOnConfig(TypedDict):134    """Configuration for an action requiring human in the loop.135 136    This is the configuration format used in the `HumanInTheLoopMiddleware.__init__`137    method.138    """139 140    allowed_decisions: list[DecisionType]141    """The decisions that are allowed for this action."""142 143    description: NotRequired[str | _DescriptionFactory]144    """The description attached to the request for human input.145 146    Can be either:147 148    - A static string describing the approval request149    - A callable that dynamically generates the description based on agent state,150        runtime, and tool call information151 152    Example:153        ```python154        # Static string description155        config = ToolConfig(156            allowed_decisions=["approve", "reject"],157            description="Please review this tool execution"158        )159 160        # Dynamic callable description161        def format_tool_description(162            tool_call: ToolCall,163            state: AgentState,164            runtime: Runtime[ContextT]165        ) -> str:166            import json167            return (168                f"Tool: {tool_call['name']}\\n"169                f"Arguments:\\n{json.dumps(tool_call['args'], indent=2)}"170            )171 172        config = InterruptOnConfig(173            allowed_decisions=["approve", "edit", "reject"],174            description=format_tool_description175        )176        ```177    """178    args_schema: NotRequired[dict[str, Any]]179    """JSON schema for the args associated with the action, if edits are allowed."""180 181 182class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):183    """Human in the loop middleware."""184 185    def __init__(186        self,187        interrupt_on: dict[str, bool | InterruptOnConfig],188        *,189        description_prefix: str = "Tool execution requires approval",190    ) -> None:191        """Initialize the human in the loop middleware.192 193        Args:194            interrupt_on: Mapping of tool name to allowed actions.195 196                If a tool doesn't have an entry, it's auto-approved by default.197 198                * `True` indicates all decisions are allowed: approve, edit, reject,199                    and respond.200                * `False` indicates that the tool is auto-approved.201                * `InterruptOnConfig` indicates the specific decisions allowed for this202                    tool.203 204                    The `InterruptOnConfig` can include a `description` field (`str` or205                    `Callable`) for custom formatting of the interrupt description.206            description_prefix: The prefix to use when constructing action requests.207 208                This is used to provide context about the tool call and the action being209                requested.210 211                Not used if a tool has a `description` in its `InterruptOnConfig`.212        """213        super().__init__()214        resolved_configs: dict[str, InterruptOnConfig] = {}215        for tool_name, tool_config in interrupt_on.items():216            if isinstance(tool_config, bool):217                if tool_config is True:218                    resolved_configs[tool_name] = InterruptOnConfig(219                        allowed_decisions=["approve", "edit", "reject", "respond"]220                    )221            elif tool_config.get("allowed_decisions"):222                resolved_configs[tool_name] = tool_config223        self.interrupt_on = resolved_configs224        self.description_prefix = description_prefix225 226    def _create_action_and_config(227        self,228        tool_call: ToolCall,229        config: InterruptOnConfig,230        state: AgentState[Any],231        runtime: Runtime[ContextT],232    ) -> tuple[ActionRequest, ReviewConfig]:233        """Create an ActionRequest and ReviewConfig for a tool call."""234        tool_name = tool_call["name"]235        tool_args = tool_call["args"]236 237        # Generate description using the description field (str or callable)238        description_value = config.get("description")239        if callable(description_value):240            description = description_value(tool_call, state, runtime)241        elif description_value is not None:242            description = description_value243        else:244            description = f"{self.description_prefix}\n\nTool: {tool_name}\nArgs: {tool_args}"245 246        # Create ActionRequest with description247        action_request = ActionRequest(248            name=tool_name,249            args=tool_args,250            description=description,251        )252 253        # Create ReviewConfig254        # eventually can get tool information and populate args_schema from there255        review_config = ReviewConfig(256            action_name=tool_name,257            allowed_decisions=config["allowed_decisions"],258        )259 260        return action_request, review_config261 262    @staticmethod263    def _process_decision(264        decision: Decision,265        tool_call: ToolCall,266        config: InterruptOnConfig,267    ) -> tuple[ToolCall | None, ToolMessage | None]:268        """Process a single decision and return the revised tool call and optional tool message."""269        allowed_decisions = config["allowed_decisions"]270 271        if decision["type"] == "approve" and "approve" in allowed_decisions:272            return tool_call, None273        if decision["type"] == "edit" and "edit" in allowed_decisions:274            edited_action = decision["edited_action"]275            return (276                ToolCall(277                    type="tool_call",278                    name=edited_action["name"],279                    args=edited_action["args"],280                    id=tool_call["id"],281                ),282                None,283            )284        if decision["type"] == "reject" and "reject" in allowed_decisions:285            # Create a tool message with the human's text response286            content = decision.get("message") or (287                f"User rejected the tool call for `{tool_call['name']}` with id {tool_call['id']}"288            )289            tool_message = ToolMessage(290                content=content,291                name=tool_call["name"],292                tool_call_id=tool_call["id"],293                status="error",294            )295            return tool_call, tool_message296        if decision["type"] == "respond" and "respond" in allowed_decisions:297            # Skip tool execution; the human answers on behalf of the tool.298            tool_message = ToolMessage(299                content=decision["message"],300                name=tool_call["name"],301                tool_call_id=tool_call["id"],302                status="success",303            )304            return tool_call, tool_message305        msg = (306            f"Unexpected human decision: {decision}. "307            f"Decision type '{decision.get('type')}' "308            f"is not allowed for tool '{tool_call['name']}'. "309            f"Expected one of {allowed_decisions} based on the tool's configuration."310        )311        raise ValueError(msg)312 313    def after_model(314        self, state: AgentState[Any], runtime: Runtime[ContextT]315    ) -> dict[str, Any] | None:316        """Trigger interrupt flows for relevant tool calls after an `AIMessage`.317 318        Args:319            state: The current agent state.320            runtime: The runtime context.321 322        Returns:323            Updated message with the revised tool calls.324 325        Raises:326            ValueError: If the number of human decisions does not match the number of327                interrupted tool calls.328        """329        messages = state["messages"]330        if not messages:331            return None332 333        last_ai_msg = next((msg for msg in reversed(messages) if isinstance(msg, AIMessage)), None)334        if not last_ai_msg or not last_ai_msg.tool_calls:335            return None336 337        # Create action requests and review configs for tools that need approval338        action_requests: list[ActionRequest] = []339        review_configs: list[ReviewConfig] = []340        interrupt_indices: list[int] = []341 342        for idx, tool_call in enumerate(last_ai_msg.tool_calls):343            if (config := self.interrupt_on.get(tool_call["name"])) is not None:344                action_request, review_config = self._create_action_and_config(345                    tool_call, config, state, runtime346                )347                action_requests.append(action_request)348                review_configs.append(review_config)349                interrupt_indices.append(idx)350 351        # If no interrupts needed, return early352        if not action_requests:353            return None354 355        # Create single HITLRequest with all actions and configs356        hitl_request = HITLRequest(357            action_requests=action_requests,358            review_configs=review_configs,359        )360 361        # Send interrupt and get response362        decisions = interrupt(hitl_request)["decisions"]363 364        # Validate that the number of decisions matches the number of interrupt tool calls365        if (decisions_len := len(decisions)) != (interrupt_count := len(interrupt_indices)):366            msg = (367                f"Number of human decisions ({decisions_len}) does not match "368                f"number of hanging tool calls ({interrupt_count})."369            )370            raise ValueError(msg)371 372        # Process decisions and rebuild tool calls in original order373        revised_tool_calls: list[ToolCall] = []374        artificial_tool_messages: list[ToolMessage] = []375        decision_idx = 0376 377        for idx, tool_call in enumerate(last_ai_msg.tool_calls):378            if idx in interrupt_indices:379                # This was an interrupt tool call - process the decision380                config = self.interrupt_on[tool_call["name"]]381                decision = decisions[decision_idx]382                decision_idx += 1383 384                revised_tool_call, tool_message = self._process_decision(385                    decision, tool_call, config386                )387                if revised_tool_call is not None:388                    revised_tool_calls.append(revised_tool_call)389                if tool_message:390                    artificial_tool_messages.append(tool_message)391            else:392                # This was auto-approved - keep original393                revised_tool_calls.append(tool_call)394 395        # Update the AI message to only include approved tool calls396        last_ai_msg.tool_calls = revised_tool_calls397 398        return {"messages": [last_ai_msg, *artificial_tool_messages]}399 400    async def aafter_model(401        self, state: AgentState[Any], runtime: Runtime[ContextT]402    ) -> dict[str, Any] | None:403        """Async trigger interrupt flows for relevant tool calls after an `AIMessage`.404 405        Args:406            state: The current agent state.407            runtime: The runtime context.408 409        Returns:410            Updated message with the revised tool calls.411        """412        return self.after_model(state, runtime)413 
codekingpro/portable-devtools · Team Ai