mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
## Summary
This PR implements comprehensive improvements to the human-in-the-loop
(HITL) review system, including safety features, architectural changes,
and bug fixes:
### Key Features
- **SECRT-1798: One-time safety popup** - Shows informational popup
before first run of AI-generated agents with sensitive actions/HITL
blocks
- **SECRT-1795: Auto-approval toggle UX** - Toggle in pending reviews
panel to auto-approve future actions from the same node
- **Node-specific auto-approval** - Changed from execution-specific to
node-specific using special key pattern
`auto_approve_{graph_exec_id}_{node_id}`
- **Consolidated approval checking** - Merged `check_auto_approval` into
`check_approval` using single OR query for better performance
- **Race condition prevention** - Added execution status check before
resuming to prevent duplicate execution when approving while graph is
running
- **Parallel auto-approval creation** - Uses `asyncio.gather` for better
performance when creating multiple auto-approval records
## Changes
### Backend Architecture
- **`human_review.py`**:
- Added `check_approval()` function that checks both normal and
auto-approval in single query
- Added `create_auto_approval_record()` for node-specific auto-approval
using special key pattern
- Added `get_auto_approve_key()` helper to generate consistent
auto-approval keys
- **`review/routes.py`**:
- Added execution status check before resuming to prevent race
conditions
- Refactored auto-approval record creation to use parallel execution
with `asyncio.gather`
- Removed obvious comments for cleaner code
- **`review/model.py`**: Added `auto_approve_future_actions` field to
`ReviewRequest`
- **`blocks/helpers/review.py`**: Updated to use consolidated
`check_approval` via database manager client
- **`executor/database.py`**: Exposed `check_approval` through
DatabaseManager RPC for block execution context
- **`data/block.py`**: Fixed safe mode checks for sensitive action
blocks
### Frontend
- **New `AIAgentSafetyPopup`** component with localStorage-based
one-time display
- **`PendingReviewsList`**:
- Replaced "Approve all future actions" button with toggle
- Toggle resets data to original values and disables editing when
enabled
- Shows warning message explaining auto-approval behavior
- **`RunAgentModal`**: Integrated safety popup before first run
- **`usePendingReviews`**: Added polling for real-time badge updates
- **`FloatingSafeModeToggle` & `SafeModeToggle`**: Simplified visibility
logic
- **`local-storage.ts`**: Added localStorage key for popup state
tracking
### Bug Fixes
- Fixed "Client is not connected to query engine" error by using
database manager client pattern
- Fixed race condition where approving reviews while graph is RUNNING
could queue execution twice
- Fixed migration to only drop FK constraint, not non-existent column
- Fixed card data reset when auto-approve toggle changes
### Code Quality
- Removed duplicate/obvious comments
- Moved imports to top-level instead of local scope in tests
- Used walrus operator for cleaner conditional assignments
- Parallel execution for auto-approval record creation
## Test plan
- [ ] Create an AI-generated agent with sensitive actions (e.g., email
sending)
- [ ] First run should show the safety popup before starting
- [ ] Subsequent runs should not show the popup
- [ ] Clear localStorage (`AI_AGENT_SAFETY_POPUP_SHOWN`) to verify popup
shows again
- [ ] Create an agent with human-in-the-loop blocks
- [ ] Run it and verify the pending reviews panel appears
- [ ] Enable the "Auto-approve all future actions" toggle
- [ ] Verify editing is disabled and shows warning message
- [ ] Click "Approve" and verify subsequent blocks from same node
auto-approve
- [ ] Verify auto-approval persists across multiple executions of same
graph
- [ ] Disable toggle and verify editing works normally
- [ ] Verify "Reject" button still works regardless of toggle state
- [ ] Test race condition: Approve reviews while graph is RUNNING
(should skip resume)
- [ ] Test race condition: Approve reviews while graph is REVIEW (should
resume)
- [ ] Verify pending reviews badge updates in real-time when new reviews
are created
226 lines
8.0 KiB
Python
226 lines
8.0 KiB
Python
import enum
|
|
from typing import Any
|
|
|
|
from backend.data.block import (
|
|
Block,
|
|
BlockCategory,
|
|
BlockOutput,
|
|
BlockSchemaInput,
|
|
BlockSchemaOutput,
|
|
BlockType,
|
|
)
|
|
from backend.data.model import SchemaField
|
|
from backend.util.file import store_media_file
|
|
from backend.util.type import MediaFileType, convert
|
|
|
|
|
|
class FileStoreBlock(Block):
|
|
class Input(BlockSchemaInput):
|
|
file_in: MediaFileType = SchemaField(
|
|
description="The file to store in the temporary directory, it can be a URL, data URI, or local path."
|
|
)
|
|
base_64: bool = SchemaField(
|
|
description="Whether produce an output in base64 format (not recommended, you can pass the string path just fine accross blocks).",
|
|
default=False,
|
|
advanced=True,
|
|
title="Produce Base64 Output",
|
|
)
|
|
|
|
class Output(BlockSchemaOutput):
|
|
file_out: MediaFileType = SchemaField(
|
|
description="The relative path to the stored file in the temporary directory."
|
|
)
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="cbb50872-625b-42f0-8203-a2ae78242d8a",
|
|
description="Stores the input file in the temporary directory.",
|
|
categories={BlockCategory.BASIC, BlockCategory.MULTIMEDIA},
|
|
input_schema=FileStoreBlock.Input,
|
|
output_schema=FileStoreBlock.Output,
|
|
static_output=True,
|
|
)
|
|
|
|
async def run(
|
|
self,
|
|
input_data: Input,
|
|
*,
|
|
graph_exec_id: str,
|
|
user_id: str,
|
|
**kwargs,
|
|
) -> BlockOutput:
|
|
yield "file_out", await store_media_file(
|
|
graph_exec_id=graph_exec_id,
|
|
file=input_data.file_in,
|
|
user_id=user_id,
|
|
return_content=input_data.base_64,
|
|
)
|
|
|
|
|
|
class StoreValueBlock(Block):
|
|
"""
|
|
This block allows you to provide a constant value as a block, in a stateless manner.
|
|
The common use-case is simply pass the `input` data, it will `output` the same data.
|
|
The block output will be static, the output can be consumed multiple times.
|
|
"""
|
|
|
|
class Input(BlockSchemaInput):
|
|
input: Any = SchemaField(
|
|
description="Trigger the block to produce the output. "
|
|
"The value is only used when `data` is None."
|
|
)
|
|
data: Any = SchemaField(
|
|
description="The constant data to be retained in the block. "
|
|
"This value is passed as `output`.",
|
|
default=None,
|
|
)
|
|
|
|
class Output(BlockSchemaOutput):
|
|
output: Any = SchemaField(description="The stored data retained in the block.")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="1ff065e9-88e8-4358-9d82-8dc91f622ba9",
|
|
description="A basic block that stores and forwards a value throughout workflows, allowing it to be reused without changes across multiple blocks.",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=StoreValueBlock.Input,
|
|
output_schema=StoreValueBlock.Output,
|
|
test_input=[
|
|
{"input": "Hello, World!"},
|
|
{"input": "Hello, World!", "data": "Existing Data"},
|
|
],
|
|
test_output=[
|
|
("output", "Hello, World!"), # No data provided, so trigger is returned
|
|
("output", "Existing Data"), # Data is provided, so data is returned.
|
|
],
|
|
static_output=True,
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
yield "output", input_data.data or input_data.input
|
|
|
|
|
|
class PrintToConsoleBlock(Block):
|
|
class Input(BlockSchemaInput):
|
|
text: Any = SchemaField(description="The data to print to the console.")
|
|
|
|
class Output(BlockSchemaOutput):
|
|
output: Any = SchemaField(description="The data printed to the console.")
|
|
status: str = SchemaField(description="The status of the print operation.")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="f3b1c1b2-4c4f-4f0d-8d2f-4c4f0d8d2f4c",
|
|
description="A debugging block that outputs text to the console for monitoring and troubleshooting workflow execution.",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=PrintToConsoleBlock.Input,
|
|
output_schema=PrintToConsoleBlock.Output,
|
|
test_input={"text": "Hello, World!"},
|
|
is_sensitive_action=True,
|
|
test_output=[
|
|
("output", "Hello, World!"),
|
|
("status", "printed"),
|
|
],
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
yield "output", input_data.text
|
|
yield "status", "printed"
|
|
|
|
|
|
class NoteBlock(Block):
|
|
class Input(BlockSchemaInput):
|
|
text: str = SchemaField(description="The text to display in the sticky note.")
|
|
|
|
class Output(BlockSchemaOutput):
|
|
output: str = SchemaField(description="The text to display in the sticky note.")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="cc10ff7b-7753-4ff2-9af6-9399b1a7eddc",
|
|
description="A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes.",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=NoteBlock.Input,
|
|
output_schema=NoteBlock.Output,
|
|
test_input={"text": "Hello, World!"},
|
|
test_output=[
|
|
("output", "Hello, World!"),
|
|
],
|
|
block_type=BlockType.NOTE,
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
yield "output", input_data.text
|
|
|
|
|
|
class TypeOptions(enum.Enum):
|
|
STRING = "string"
|
|
NUMBER = "number"
|
|
BOOLEAN = "boolean"
|
|
LIST = "list"
|
|
DICTIONARY = "dictionary"
|
|
|
|
|
|
class UniversalTypeConverterBlock(Block):
|
|
class Input(BlockSchemaInput):
|
|
value: Any = SchemaField(
|
|
description="The value to convert to a universal type."
|
|
)
|
|
type: TypeOptions = SchemaField(description="The type to convert the value to.")
|
|
|
|
class Output(BlockSchemaOutput):
|
|
value: Any = SchemaField(description="The converted value.")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="95d1b990-ce13-4d88-9737-ba5c2070c97b",
|
|
description="This block is used to convert a value to a universal type.",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=UniversalTypeConverterBlock.Input,
|
|
output_schema=UniversalTypeConverterBlock.Output,
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
try:
|
|
converted_value = convert(
|
|
input_data.value,
|
|
{
|
|
TypeOptions.STRING: str,
|
|
TypeOptions.NUMBER: float,
|
|
TypeOptions.BOOLEAN: bool,
|
|
TypeOptions.LIST: list,
|
|
TypeOptions.DICTIONARY: dict,
|
|
}[input_data.type],
|
|
)
|
|
yield "value", converted_value
|
|
except Exception as e:
|
|
yield "error", f"Failed to convert value: {str(e)}"
|
|
|
|
|
|
class ReverseListOrderBlock(Block):
|
|
"""
|
|
A block which takes in a list and returns it in the opposite order.
|
|
"""
|
|
|
|
class Input(BlockSchemaInput):
|
|
input_list: list[Any] = SchemaField(description="The list to reverse")
|
|
|
|
class Output(BlockSchemaOutput):
|
|
reversed_list: list[Any] = SchemaField(description="The list in reversed order")
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
id="422cb708-3109-4277-bfe3-bc2ae5812777",
|
|
description="Reverses the order of elements in a list",
|
|
categories={BlockCategory.BASIC},
|
|
input_schema=ReverseListOrderBlock.Input,
|
|
output_schema=ReverseListOrderBlock.Output,
|
|
test_input={"input_list": [1, 2, 3, 4, 5]},
|
|
test_output=[("reversed_list", [5, 4, 3, 2, 1])],
|
|
)
|
|
|
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
|
reversed_list = list(input_data.input_list)
|
|
reversed_list.reverse()
|
|
yield "reversed_list", reversed_list
|