Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions web_hacker/agents/guide_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ def __init__(
emit_message_callable: Callable[[EmittedMessage], None],
persist_chat_callable: Callable[[Chat], Chat] | None = None,
persist_chat_thread_callable: Callable[[ChatThread], ChatThread] | None = None,
persist_suggested_edit_callable: Callable[[SuggestedEditRoutine], SuggestedEditRoutine] | None = None,
stream_chunk_callable: Callable[[str], None] | None = None,
llm_model: OpenAIModel = OpenAIModel.GPT_5_1,
chat_thread: ChatThread | None = None,
Expand All @@ -244,6 +245,8 @@ def __init__(
Returns the Chat with the final ID assigned by the persistence layer.
persist_chat_thread_callable: Optional callback to persist ChatThread (for DynamoDB).
Returns the ChatThread with the final ID assigned by the persistence layer.
persist_suggested_edit_callable: Optional callback to persist SuggestedEditRoutine objects.
Returns the SuggestedEditRoutine with the final ID assigned by the persistence layer.
stream_chunk_callable: Optional callback for streaming text chunks as they arrive.
llm_model: The LLM model to use for conversation.
chat_thread: Existing ChatThread to continue, or None for new conversation.
Expand All @@ -255,6 +258,7 @@ def __init__(
self._emit_message_callable = emit_message_callable
self._persist_chat_callable = persist_chat_callable
self._persist_chat_thread_callable = persist_chat_thread_callable
self._persist_suggested_edit_callable = persist_suggested_edit_callable
self._stream_chunk_callable = stream_chunk_callable
self._data_store = data_store
self._tools_requiring_approval = tools_requiring_approval or set()
Expand Down Expand Up @@ -575,6 +579,16 @@ def _tool_suggest_routine_edit(self, tool_arguments: dict[str, Any]) -> dict[str
routine=routine,
)

# Persist suggested edit if callback provided (may assign new ID)
if self._persist_suggested_edit_callable:
suggested_edit = self._persist_suggested_edit_callable(suggested_edit)

# Add to thread's suggested_edit_ids and persist thread
self._thread.suggested_edit_ids.append(suggested_edit.id)
self._thread.updated_at = int(datetime.now().timestamp())
if self._persist_chat_thread_callable:
self._thread = self._persist_chat_thread_callable(self._thread)

# Emit the suggested edit for host to handle
self._emit_message(
EmittedMessage(
Expand Down
15 changes: 6 additions & 9 deletions web_hacker/data_models/llms/interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from pydantic import BaseModel, Field

from web_hacker.data_models.resource_base import ResourceBase
from web_hacker.data_models.routine import Routine

class ChatRole(StrEnum):
Expand Down Expand Up @@ -50,22 +51,14 @@ class SuggestedEditStatus(StrEnum):
REJECTED = "rejected"


class SuggestedEdit(BaseModel):
class SuggestedEdit(ResourceBase):
"""
Base model for suggested edits that require user approval.
"""
id: str = Field(
default_factory=lambda: str(uuid4()),
description="Unique edit ID (UUIDv4)",
)
type: SuggestedEditType = Field(
...,
description="Type of suggested edit",
)
created_at: int = Field(
default_factory=lambda: int(datetime.now().timestamp() * 1_000),
description="Unix timestamp (milliseconds) when resource was created",
)
status: SuggestedEditStatus = Field(
default=SuggestedEditStatus.PENDING,
description="Current status of the suggested edit",
Expand Down Expand Up @@ -254,6 +247,10 @@ class ChatThread(BaseModel):
default_factory=list,
description="Ordered list of message IDs in this thread",
)
suggested_edit_ids : list[str] = Field(
default_factory=list,
description="List of suggested edit IDs in this thread",
)
pending_tool_invocation: PendingToolInvocation | None = Field(
default=None,
description="Tool invocation awaiting user confirmation, if any",
Expand Down
65 changes: 65 additions & 0 deletions web_hacker/data_models/resource_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
web_hacker/data_models/resource_base.py

Base class for all resources that provides a standardized ID format.

ID format: [resourceType]_[uuidv4]
Examples: "Routine_123e4567-e89b-12d3-a456-426614174000"
"""

from abc import ABC
from datetime import datetime
from typing import Any
from uuid import uuid4

from pydantic import BaseModel, Field


class ResourceBase(BaseModel, ABC):
"""
Base class for all resources that provides a standardized ID format.

ID format: [resourceType]_[uuidv4]
Examples: "Routine_123e4567-e89b-12d3-a456-426614174000"
"""

# standardized resource ID in format "[resourceType]_[uuid]"
id: str = Field(
default_factory=lambda: f"ResourceBase_{uuid4()}",
description="Resource ID in format [resourceType]_[uuidv4]"
)

created_at: int = Field(
default_factory=lambda: int(datetime.now().timestamp() * 1_000),
description="Unix timestamp (milliseconds) when resource was created"
)
updated_at: int = Field(
default_factory=lambda: int(datetime.now().timestamp() * 1_000),
description="Unix timestamp (milliseconds) when resource was last updated"
)
metadata: dict[str, Any] | None = Field(
default=None,
description="Metadata for the resource. Anythning that is not suitable for a regular field."
)

@property
def resource_type(self) -> str:
"""
Return the resource type name (class name) for this class.
"""
return self.__class__.__name__

def __init_subclass__(cls, **kwargs) -> None:
"""
Initialize subclass by setting up the correct default_factory for the id field.
This method is called when a class inherits from ResourceBase. It ensures
that each subclass gets an id field with a default_factory that generates
IDs in the format "[ClassName]_[uuid4]".
Args:
cls: The subclass being initialized
**kwargs: Additional keyword arguments passed to the subclass
"""
super().__init_subclass__(**kwargs)
# override the default_factory for the id field to use the actual class name
if hasattr(cls, 'model_fields') and 'id' in cls.model_fields:
cls.model_fields['id'].default_factory = lambda: f"{cls.__name__}_{uuid4()}"