diff --git a/CHANGELOG.md b/CHANGELOG.md index 24376a29..414b3378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added support for submitting multiple chats in one batch. With batch submission, results can take up to 24 hours to complete, but in return you pay ~50% less than usual. For more, see the [reference](https://posit-dev.github.io/chatlas/reference/) for `batch_chat()`, `batch_chat_text()`, `batch_chat_structured()` and `batch_chat_completed()`. (#177) * The `Chat` class gains new `.chat_structured()` (and `.chat_structured_async()`) methods. These methods supersede the now deprecated `.extract_data()` (and `.extract_data_async()`). The only difference is that the new methods return a `BaseModel` instance (instead of a `dict()`), leading to a better type hinting/checking experience. (#175) +* The `.get_turns()` method gains a `tool_result_role` parameter. Set `tool_result_role="assistant"` to collect tool result content (plus the surrounding assistant turn contents) into a single assistant turn. This is convenient for display purposes and more generally if you want the tool calling loop to be contained in a single turn. (#179) + +### Improvements + +* The `.app()` method now: + * Enables bookmarking by default (i.e., chat session survives page reload). (#179) + * Correctly renders pre-existing turns that contain tool calls. (#179) ## [0.12.0] - 2025-09-08 diff --git a/chatlas/_chat.py b/chatlas/_chat.py index 0d97c1bd..81669757 100644 --- a/chatlas/_chat.py +++ b/chatlas/_chat.py @@ -210,6 +210,7 @@ def get_turns( self, *, include_system_prompt: bool = False, + tool_result_role: Literal["assistant", "user"] = "user", ) -> list[Turn[CompletionT]]: """ Get all the turns (i.e., message contents) in the chat. @@ -218,14 +219,50 @@ def get_turns( ---------- include_system_prompt Whether to include the system prompt in the turns. + tool_result_role + The role to assign to turns containing tool results. By default, + tool results are assigned a role of "user" since they represent + information provided to the assistant. If set to "assistant" tool + result content (plus the surrounding assistant turn contents) is + collected into a single assistant turn. This is convenient for + display purposes and more generally if you want the tool calling + loop to be contained in a single turn. """ if not self._turns: return self._turns if not include_system_prompt and self._turns[0].role == "system": - return self._turns[1:] - return self._turns + turns = self._turns[1:] + else: + turns = self._turns + + if tool_result_role == "user": + return turns + + if tool_result_role != "assistant": + raise ValueError( + f"Expected `tool_result_role` to be one of 'user' or 'assistant', not '{tool_result_role}'" + ) + + # If a turn is purely a tool result, change its role + turns2 = copy.deepcopy(turns) + for turn in turns2: + if all(isinstance(c, ContentToolResult) for c in turn.contents): + turn.role = tool_result_role + + # If two consecutive turns have the same role (i.e., assistant), collapse them into one + final_turns: list[Turn[CompletionT]] = [] + for x in turns2: + if not final_turns: + final_turns.append(x) + continue + if x.role != final_turns[-1].role: + final_turns.append(x) + else: + final_turns[-1].contents.extend(x.contents) + + return final_turns def get_last_turn( self, @@ -609,6 +646,7 @@ def app( port: int = 0, host: str = "127.0.0.1", launch_browser: bool = True, + bookmark_store: Literal["url", "server", "disable"] = "url", bg_thread: Optional[bool] = None, echo: Optional[EchoOptions] = None, content: Literal["text", "all"] = "all", @@ -627,6 +665,12 @@ def app( The host to run the app on (the default is "127.0.0.1"). launch_browser Whether to launch a browser window. + bookmark_store + One of the following (default is "url"): + - `"url"`: Store bookmarks in the URL (default). + - `"server"`: Store bookmarks on the server (requires a server-side + storage backend). + - `"disable"`: Disable bookmarking. bg_thread Whether to run the app in a background thread. If `None`, the app will run in a background thread if the current environment is a notebook. @@ -648,24 +692,37 @@ def app( from shiny import App, run_app, ui except ImportError: raise ImportError( - "The `shiny` package is required for the `browser` method. " + "The `shiny` package is required for the `app()` method. " "Install it with `pip install shiny`." ) - app_ui = ui.page_fillable( - ui.chat_ui("chat"), - fillable_mobile=True, - ) + try: + from shinychat import ( + Chat, + chat_ui, + message_content, # pyright: ignore[reportAttributeAccessIssue] + ) + except ImportError: + raise ImportError( + "The `shinychat` package is required for the `app()` method. " + "Install it with `pip install shinychat`." + ) - def server(input): # noqa: A002 - chat = ui.Chat( - "chat", - messages=[ - {"role": turn.role, "content": turn.text} - for turn in self.get_turns() - ], + messages = [ + message_content(x) for x in self.get_turns(tool_result_role="assistant") + ] + + def app_ui(x): + return ui.page_fillable( + chat_ui("chat", messages=messages), + fillable_mobile=True, ) + def server(input): # noqa: A002 + chat = Chat("chat") + + chat.enable_bookmarking(self) + @chat.on_user_submit async def _(user_input: str): if stream: @@ -689,7 +746,7 @@ async def _(user_input: str): ) ) - app = App(app_ui, server) + app = App(app_ui, server, bookmark_store=bookmark_store) def _run_app(): run_app(app, launch_browser=launch_browser, port=port, host=host) diff --git a/pyproject.toml b/pyproject.toml index c53abeb3..54e4687e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "matplotlib", "Pillow", "shiny", + "shinychat", "openai", "anthropic[bedrock]", "google-genai>=1.14.0", diff --git a/tests/test_turns.py b/tests/test_turns.py index 450f26c1..b633e665 100644 --- a/tests/test_turns.py +++ b/tests/test_turns.py @@ -1,8 +1,9 @@ import pytest from chatlas import ChatAnthropic +from chatlas._content import ToolInfo from chatlas._turn import Turn -from chatlas.types import ContentJson, ContentText +from chatlas.types import ContentJson, ContentText, ContentToolRequest, ContentToolResult def test_system_prompt_applied_correctly(): @@ -90,3 +91,260 @@ def test_can_extract_text_easily(): ], ) assert turn.text == "ABCDEF" + + +def test_get_turns_tool_result_role_default(): + """Test that tool results have default role of 'user'""" + chat = ChatAnthropic() + + # Create mock tool request and result + tool_request = ContentToolRequest( + id="test-123", + name="add", + arguments={"x": 1, "y": 2}, + ) + tool_result = ContentToolResult( + id="test-123", + name="add", + arguments={"x": 1, "y": 2}, + value=3, + request=tool_request + ) + + # Add turns with mixed content + user_turn = Turn("user", "What is 1 + 2?") + assistant_turn = Turn("assistant", [ContentText(text="I'll calculate that."), tool_request]) + tool_result_turn = Turn("user", [tool_result]) + final_assistant_turn = Turn("assistant", "The answer is 3.") + + chat.set_turns([user_turn, assistant_turn, tool_result_turn, final_assistant_turn]) + + # Default behavior should keep tool result turn as "user" + turns = chat.get_turns() + assert len(turns) == 4 + assert turns[2].role == "user" # Tool result turn remains as user + assert all(isinstance(c, ContentToolResult) for c in turns[2].contents) + + +def test_get_turns_tool_result_role_assistant(): + """Test tool_result_role='assistant' changes tool result turn roles""" + chat = ChatAnthropic() + + # Create mock tool request and result + tool_request = ContentToolRequest( + id="test-123", + name="add", + arguments={"x": 1, "y": 2}, + ) + tool_result = ContentToolResult( + id="test-123", + name="add", + arguments={"x": 1, "y": 2}, + value=3, + request=tool_request + ) + + # Add turns with mixed content + user_turn = Turn("user", "What is 1 + 2?") + assistant_turn = Turn("assistant", [ContentText(text="I'll calculate that."), tool_request]) + tool_result_turn = Turn("user", [tool_result]) + final_assistant_turn = Turn("assistant", "The answer is 3.") + + chat.set_turns([user_turn, assistant_turn, tool_result_turn, final_assistant_turn]) + + # With tool_result_role="assistant", tool result turns should change to assistant + turns = chat.get_turns(tool_result_role="assistant") + assert len(turns) == 2 # Should be collapsed due to consecutive assistant turns + assert turns[0].role == "user" + assert turns[1].role == "assistant" # All assistant content collapsed + + # The second turn should now contain content from all assistant turns and tool result + assert len(turns[1].contents) == 4 # Text + tool_request + tool_result + Text + assert isinstance(turns[1].contents[0], ContentText) + assert isinstance(turns[1].contents[1], ContentToolRequest) + assert isinstance(turns[1].contents[2], ContentToolResult) + assert isinstance(turns[1].contents[3], ContentText) + + +def test_get_turns_tool_result_role_collapse_consecutive(): + """Test that consecutive assistant turns are collapsed when tool_result_role='assistant'""" + chat = ChatAnthropic() + + # Create multiple tool results + tool_result1 = ContentToolResult( + id="test-1", + name="add", + arguments={"x": 1, "y": 2}, + value=3, + ) + tool_result2 = ContentToolResult( + id="test-2", + name="multiply", + arguments={"x": 3, "y": 4}, + value=12, + ) + + # Create turns with multiple consecutive assistant turns via tool results + user_turn = Turn("user", "Do some math") + assistant_turn1 = Turn("assistant", "Let me calculate.") + tool_result_turn1 = Turn("user", [tool_result1]) + tool_result_turn2 = Turn("user", [tool_result2]) + assistant_turn2 = Turn("assistant", "Done with calculations.") + + chat.set_turns([user_turn, assistant_turn1, tool_result_turn1, tool_result_turn2, assistant_turn2]) + + # Default behavior - no collapsing + turns_default = chat.get_turns() + assert len(turns_default) == 5 + + # With tool_result_role="assistant" - should collapse consecutive assistant turns + turns_assistant = chat.get_turns(tool_result_role="assistant") + assert len(turns_assistant) == 2 + assert turns_assistant[0].role == "user" + assert turns_assistant[1].role == "assistant" + + # Second turn should have content from all assistant turns and tool results + assert len(turns_assistant[1].contents) == 4 # Text + tool_result1 + tool_result2 + Text + assert isinstance(turns_assistant[1].contents[0], ContentText) + assert isinstance(turns_assistant[1].contents[1], ContentToolResult) + assert isinstance(turns_assistant[1].contents[2], ContentToolResult) + assert isinstance(turns_assistant[1].contents[3], ContentText) + + +def test_get_turns_tool_result_role_mixed_content(): + """Test tool_result_role with turns containing mixed content""" + chat = ChatAnthropic() + + tool_result = ContentToolResult( + id="test-123", + name="weather", + arguments={"city": "Boston"}, + value={"temp": 75, "condition": "sunny"}, + ) + + # Turn with mixed content (text + tool result) + user_turn = Turn("user", "Check the weather") + assistant_turn = Turn("assistant", "I'll check that.") + mixed_turn = Turn("user", [ContentText(text="Here's additional info:"), tool_result]) + final_turn = Turn("assistant", "The weather is nice!") + + chat.set_turns([user_turn, assistant_turn, mixed_turn, final_turn]) + + # Default behavior - turn with mixed content stays as "user" + turns_default = chat.get_turns() + assert len(turns_default) == 4 + assert turns_default[2].role == "user" + + # With tool_result_role="assistant" - turn with mixed content should NOT change role + # because it's not purely tool results + turns_assistant = chat.get_turns(tool_result_role="assistant") + assert len(turns_assistant) == 4 + assert turns_assistant[2].role == "user" # Should remain user due to mixed content + + +def test_get_turns_tool_result_role_purely_tool_results(): + """Test that only turns with purely tool results change role""" + chat = ChatAnthropic() + + tool_result1 = ContentToolResult( + id="test-1", + name="add", + arguments={"x": 1, "y": 2}, + value=3, + ) + tool_result2 = ContentToolResult( + id="test-2", + name="multiply", + arguments={"x": 3, "y": 4}, + value=12, + ) + + # Create different types of turns + user_turn = Turn("user", "Do calculations") + assistant_turn = Turn("assistant", "I'll help.") + pure_tool_turn = Turn("user", [tool_result1, tool_result2]) # Pure tool results + mixed_tool_turn = Turn("user", [ContentText(text="Results:"), tool_result1]) # Mixed content + final_turn = Turn("assistant", "All done.") + + chat.set_turns([user_turn, assistant_turn, pure_tool_turn, mixed_tool_turn, final_turn]) + + # With tool_result_role="assistant" + turns = chat.get_turns(tool_result_role="assistant") + + # Pure tool turn should change role and be collapsed with assistant turns + # Mixed tool turn should remain as user + expected_roles = ["user", "assistant", "user", "assistant"] + actual_roles = [turn.role for turn in turns] + assert actual_roles == expected_roles + + +def test_get_turns_tool_result_role_invalid_value(): + """Test that invalid tool_result_role values raise ValueError""" + chat = ChatAnthropic() + + # Add some content so validation is triggered + user_turn = Turn("user", "Hello") + chat.set_turns([user_turn]) + + with pytest.raises(ValueError, match="Expected `tool_result_role` to be one of 'user' or 'assistant', not 'invalid'"): + chat.get_turns(tool_result_role="invalid") + + +def test_get_turns_tool_result_role_with_system_prompt(): + """Test tool_result_role works correctly with system prompt inclusion""" + chat = ChatAnthropic(system_prompt="You are a helpful assistant.") + + tool_result = ContentToolResult( + id="test-123", + name="calculate", + arguments={"expr": "2+2"}, + value=4, + ) + + user_turn = Turn("user", "Calculate 2+2") + assistant_turn = Turn("assistant", "I'll calculate that.") + tool_result_turn = Turn("user", [tool_result]) + final_turn = Turn("assistant", "The answer is 4.") + + chat.set_turns([user_turn, assistant_turn, tool_result_turn, final_turn]) + + # Test with system prompt included + turns_with_system = chat.get_turns(include_system_prompt=True, tool_result_role="assistant") + assert len(turns_with_system) == 3 # system + user + collapsed assistant turns + assert turns_with_system[0].role == "system" + assert turns_with_system[1].role == "user" + assert turns_with_system[2].role == "assistant" + + # Test without system prompt + turns_without_system = chat.get_turns(include_system_prompt=False, tool_result_role="assistant") + assert len(turns_without_system) == 2 # user + collapsed assistant turns + assert turns_without_system[0].role == "user" + assert turns_without_system[1].role == "assistant" + + +def test_get_turns_tool_result_role_empty_chat(): + """Test tool_result_role with empty chat""" + chat = ChatAnthropic() + + # Empty chat should return empty list regardless of tool_result_role + assert chat.get_turns(tool_result_role="user") == [] + assert chat.get_turns(tool_result_role="assistant") == [] + + +def test_get_turns_tool_result_role_no_tool_results(): + """Test tool_result_role with chat containing no tool results""" + chat = ChatAnthropic() + + user_turn = Turn("user", "Hello") + assistant_turn = Turn("assistant", "Hi there!") + + chat.set_turns([user_turn, assistant_turn]) + + # Should behave identically regardless of tool_result_role when no tool results present + turns_user = chat.get_turns(tool_result_role="user") + turns_assistant = chat.get_turns(tool_result_role="assistant") + + assert len(turns_user) == 2 + assert len(turns_assistant) == 2 + assert turns_user[0].role == turns_assistant[0].role == "user" + assert turns_user[1].role == turns_assistant[1].role == "assistant"