-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Python: Allow branching from hosted Foundry conversations #7526
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6aae03d
02c34af
95eb679
e4d58cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -569,7 +569,9 @@ async def _handle_inner_agent( | |
|
|
||
| Foundry sessions govern hosted compute and filesystem lifetime and may | ||
| serve multiple users and Responses conversations. Conversation mode | ||
| reads and writes one MAF session snapshot under ``conversation_id``. | ||
| reads the latest MAF session snapshot under ``conversation_id`` and | ||
| writes each turn under both its immutable ``response_id`` and the | ||
| conversation ID. | ||
| Response chaining reads the snapshot under ``previous_response_id`` and | ||
| writes the updated session under the current ``response_id``, allowing | ||
| branches without changing the MAF session's own identifier. The request | ||
|
|
@@ -591,6 +593,8 @@ async def _handle_inner_agent( | |
|
|
||
| try: | ||
| approval_storage = self._approval_storage_for_request() | ||
| if request.previous_response_id is not None and context.conversation_id is not None: | ||
| raise RuntimeError("Previous response ID cannot be used in conjunction with conversation ID.") | ||
| read_session_id = context.conversation_id or request.previous_response_id | ||
| if self._session_store is None: | ||
| if read_session_id is not None: | ||
|
|
@@ -688,7 +692,9 @@ async def _handle_inner_agent( | |
| session.state.pop(_HOSTED_RESPONSES_HISTORY_SOURCE_ID, None) | ||
| if session is not None and self._session_store is not None: | ||
| try: | ||
| await self._session_store.set(context.conversation_id or context.response_id, session) | ||
| await self._session_store.set(context.response_id, session) | ||
| if context.conversation_id is not None: | ||
| await self._session_store.set(context.conversation_id, session) | ||
| except Exception as save_error: | ||
| if request_interrupted: | ||
| logger.error( | ||
|
|
@@ -808,100 +814,149 @@ async def _handle_inner_workflow( | |
| if latest_checkpoint is not None: | ||
| latest_checkpoint_id = latest_checkpoint.checkpoint_id | ||
|
|
||
| # Storage that will receive checkpoints written during this turn. | ||
| # When the caller chains with previous_response_id, the next turn | ||
| # will reference the current response_id as its previous_response_id, | ||
| # so new checkpoints must land under the current response_id (or the | ||
| # conversation_id when set). When conversation_id is set, this | ||
| # matches restore_storage; when only previous_response_id was | ||
| # supplied, restore_storage points at the *prior* response's | ||
| # directory and write_storage points at the *current* response's. | ||
| write_context_id = context.conversation_id or context.response_id | ||
| # Each turn writes to response-addressed checkpoint storage. | ||
| # Conversation continuation is updated from its latest checkpoint | ||
| # after the run. | ||
| write_context_id = context.response_id | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens if a conversation-backed workflow is cancelled or the client disconnects after the workflow has written a checkpoint?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The saving is in a finally block now, similar to when running a normal agent. |
||
| write_storage = _checkpoint_storage_for_context( | ||
| self._checkpoint_storage_path, | ||
| write_context_id, | ||
| user_id=user_id, | ||
| ) | ||
|
|
||
| # Multi-turn pattern: when we have a prior checkpoint, restore it | ||
| # first (drive the workflow back to idle with prior state intact), | ||
| # then make a separate call that delivers the new user input. This | ||
| # depends on Workflow.run preserving shared state across calls. The | ||
| # restore-only call may yield events from any pending in-flight | ||
| # work in the checkpoint; we consume those internally here so they | ||
| # don't surface to the response stream as duplicates. | ||
| # | ||
| # If the restored checkpoint had pending request_info events, the | ||
| # restore-only call replays them through | ||
| # ``WorkflowAgent._convert_workflow_event_to_agent_response_updates`` | ||
| # and populates ``self._agent.pending_requests``. That is the correct | ||
| # state: those requests are genuinely outstanding, and the next | ||
| # ``run(input_messages, ...)`` call may contain ``function_call_output`` | ||
| # items (carried as FunctionResult/FunctionApprovalResponse content) | ||
| # that fulfill them via :meth:`WorkflowAgent._process_pending_requests`. | ||
| if latest_checkpoint_id is not None: | ||
| if is_streaming_request: | ||
| async for _ in self._agent.run( | ||
| stream=True, | ||
| checkpoint_id=latest_checkpoint_id, | ||
| checkpoint_storage=restore_storage, | ||
| ): | ||
| pass | ||
| else: | ||
| await self._agent.run( | ||
| request_failure: Exception | None = None | ||
| request_interrupted = False | ||
| try: | ||
| # Multi-turn pattern: when we have a prior checkpoint, restore it | ||
| # first (drive the workflow back to idle with prior state intact), | ||
| # then make a separate call that delivers the new user input. This | ||
| # depends on Workflow.run preserving shared state across calls. The | ||
| # restore-only call may yield events from any pending in-flight | ||
| # work in the checkpoint; we consume those internally here so they | ||
| # don't surface to the response stream as duplicates. | ||
| # | ||
| # If the restored checkpoint had pending request_info events, the | ||
| # restore-only call replays them through | ||
| # ``WorkflowAgent._convert_workflow_event_to_agent_response_updates`` | ||
| # and populates ``self._agent.pending_requests``. That is the correct | ||
| # state: those requests are genuinely outstanding, and the next | ||
| # ``run(input_messages, ...)`` call may contain ``function_call_output`` | ||
| # items (carried as FunctionResult/FunctionApprovalResponse content) | ||
| # that fulfill them via :meth:`WorkflowAgent._process_pending_requests`. | ||
| if latest_checkpoint_id is not None: | ||
| if is_streaming_request: | ||
| async for _ in self._agent.run( | ||
| stream=True, | ||
| checkpoint_id=latest_checkpoint_id, | ||
| checkpoint_storage=restore_storage, | ||
| ): | ||
| pass | ||
| else: | ||
| await self._agent.run( | ||
| stream=False, | ||
| checkpoint_id=latest_checkpoint_id, | ||
| checkpoint_storage=restore_storage, | ||
| ) | ||
|
|
||
| if not is_streaming_request: | ||
| # Run the agent in non-streaming mode with the new user input. | ||
| response = await self._agent.run( | ||
| input_messages, | ||
| stream=False, | ||
| checkpoint_id=latest_checkpoint_id, | ||
| checkpoint_storage=restore_storage, | ||
| checkpoint_storage=write_storage, | ||
| ) | ||
|
|
||
| if not is_streaming_request: | ||
| # Run the agent in non-streaming mode with the new user input. | ||
| response = await self._agent.run( | ||
| input_messages, | ||
| stream=False, | ||
| checkpoint_storage=write_storage, | ||
| ) | ||
|
|
||
| async for item in _to_outputs_for_messages( | ||
| response_event_stream, | ||
| response.messages, | ||
| approval_storage=approval_storage, | ||
| ): | ||
| yield item | ||
|
|
||
| await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name) | ||
| yield response_event_stream.emit_completed() | ||
| return | ||
| async for item in _to_outputs_for_messages( | ||
| response_event_stream, | ||
| response.messages, | ||
| approval_storage=approval_storage, | ||
| ): | ||
| yield item | ||
| else: | ||
| tracker = _OutputItemTracker(response_event_stream) | ||
|
|
||
| tracker = _OutputItemTracker(response_event_stream) | ||
| # Run the workflow agent in streaming mode with the new user input. | ||
| async for update in self._agent.run( | ||
| input_messages, | ||
| stream=True, | ||
| checkpoint_storage=write_storage, | ||
| ): | ||
| for content in update.contents: | ||
| for event in tracker.handle(content): | ||
| yield event | ||
| if tracker.needs_async: | ||
| async for item in _to_outputs( | ||
| response_event_stream, content, approval_storage=approval_storage | ||
| ): | ||
| yield item | ||
| tracker.needs_async = False | ||
|
|
||
| # Run the workflow agent in streaming mode with the new user input. | ||
| async for update in self._agent.run( | ||
| input_messages, | ||
| stream=True, | ||
| checkpoint_storage=write_storage, | ||
| ): | ||
| for content in update.contents: | ||
| for event in tracker.handle(content): | ||
| # Close any remaining active builder | ||
| for event in tracker.close(): | ||
| yield event | ||
| if tracker.needs_async: | ||
| async for item in _to_outputs( | ||
| response_event_stream, content, approval_storage=approval_storage | ||
| ): | ||
| yield item | ||
| tracker.needs_async = False | ||
|
|
||
| # Close any remaining active builder | ||
| for event in tracker.close(): | ||
| yield event | ||
|
|
||
| await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name) | ||
| except asyncio.CancelledError: | ||
| request_interrupted = True | ||
| raise | ||
| except GeneratorExit: | ||
| request_interrupted = True | ||
| raise | ||
| except Exception as ex: | ||
| request_failure = ex | ||
| raise | ||
| finally: | ||
| try: | ||
| await self._finalize_workflow_checkpoints( | ||
| write_storage, | ||
| workflow_name=self._agent.workflow.name, | ||
| conversation_id=context.conversation_id, | ||
| user_id=user_id, | ||
| ) | ||
| except Exception as save_error: | ||
| if request_interrupted: | ||
| logger.error( | ||
| "Failed to finalize workflow checkpoints while unwinding an interrupted request", | ||
| exc_info=(type(save_error), save_error, save_error.__traceback__), | ||
| ) | ||
| elif request_failure is not None: | ||
| logger.error( | ||
| "Failed to finalize workflow checkpoints after a workflow failure", | ||
| exc_info=(type(save_error), save_error, save_error.__traceback__), | ||
| ) | ||
| else: | ||
| raise | ||
| yield response_event_stream.emit_completed() | ||
| except Exception as ex: | ||
| logger.exception("Failed to produce response for workflow agent") | ||
| for event in self._emit_failure(response_event_stream, tracker, ex): | ||
| yield event | ||
|
|
||
| async def _finalize_workflow_checkpoints( | ||
| self, | ||
| response_storage: FileCheckpointStorage, | ||
| *, | ||
| workflow_name: str, | ||
| conversation_id: str | None, | ||
| user_id: str | None, | ||
| ) -> None: | ||
| """Keep one response checkpoint and update the conversation's latest-state alias.""" | ||
| await self._delete_not_latest_checkpoints(response_storage, workflow_name) | ||
| if conversation_id is None: | ||
| return | ||
|
|
||
| latest_checkpoint = await response_storage.get_latest(workflow_name=workflow_name) | ||
| if latest_checkpoint is None: | ||
| return | ||
| if self._checkpoint_storage_path is None: | ||
| raise RuntimeError("Checkpoint storage path is not configured for workflow agent.") | ||
|
|
||
| conversation_storage = _checkpoint_storage_for_context( | ||
| self._checkpoint_storage_path, | ||
| conversation_id, | ||
| user_id=user_id, | ||
| ) | ||
| await conversation_storage.save(latest_checkpoint) | ||
| await self._delete_not_latest_checkpoints(conversation_storage, workflow_name) | ||
|
|
||
| @staticmethod | ||
| async def _delete_not_latest_checkpoints(checkpoint_storage: FileCheckpointStorage, workflow_name: str) -> None: | ||
| """Delete all checkpoints except the latest one. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.