diff --git a/.azure-pipelines/hosted-agent-tests/python/langgraph/responses/07-human-in-the-loop/test-spec.yml b/.azure-pipelines/hosted-agent-tests/python/langgraph/responses/07-human-in-the-loop/test-spec.yml new file mode 100644 index 000000000..277c07a27 --- /dev/null +++ b/.azure-pipelines/hosted-agent-tests/python/langgraph/responses/07-human-in-the-loop/test-spec.yml @@ -0,0 +1,32 @@ +version: 1 + +sample: + owner: jiahuigu + experiences: [azd, vscode] + +tests: + - name: approved-tasks-use-latest-user-message + turns: + - input: Draft a one-sentence launch email containing the exact marker SEATTLE-LAUNCH-071. Do not include any other city or event marker. + approvals: + mcp: + - server_label: langgraph + name: __hosted_agent_adapter_interrupt__ + assertions: + - source: assistant_text + type: contains + value: SEATTLE-LAUNCH-071 + - input: This is a new task. Draft a one-sentence invitation containing the exact marker PARIS-EVENT-072. Do not mention the previous task or its marker. + approvals: + mcp: + - server_label: langgraph + name: __hosted_agent_adapter_interrupt__ + assertions: + - source: assistant_text + type: contains + value: PARIS-EVENT-072 + - source: assistant_text + type: contains + value: SEATTLE-LAUNCH-071 + min_matches: 0 + max_matches: 0 diff --git a/.github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py b/.github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py new file mode 100644 index 000000000..6884f7745 --- /dev/null +++ b/.github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py @@ -0,0 +1,177 @@ +"""Local sample lifecycle regressions using runtime dependencies, not Azure access. + +Run from the repository root in the sample's frozen runtime environment: + python .github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from langchain_azure_ai.agents.hosting import ResponsesHostServer +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.checkpoint.memory import InMemorySaver +from starlette.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[3] +SAMPLE = ROOT / ( + "samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/" + "src/langgraph-human-in-the-loop-responses/main.py" +) + + +class LangGraphSampleLifecycleTests(unittest.TestCase): + def test_revision_then_new_task_isolates_model_context(self) -> None: + state_root = self.enterContext(tempfile.TemporaryDirectory()) + spec = importlib.util.spec_from_file_location("hitl_sample_lifecycle", SAMPLE) + self.assertIsNotNone(spec) + self.assertIsNotNone(spec.loader) + module = importlib.util.module_from_spec(spec) + model = types.SimpleNamespace( + ainvoke=AsyncMock( + side_effect=[ + AIMessage(content="SEATTLE-DRAFT-ORIGINAL"), + AIMessage(content="SEATTLE-DRAFT-REVISED"), + AIMessage(content="PARIS-DRAFT"), + ] + ) + ) + with ( + patch.dict(sys.modules, {module.__name__: module}), + patch.dict( + os.environ, + {"AGENTSERVER_STATE_ROOT": state_root, "OTEL_SDK_DISABLED": "true"}, + ), + patch("dotenv.load_dotenv"), + ): + spec.loader.exec_module(module) + with patch.object( + module, "FoundryCheckpointSaver", return_value=InMemorySaver() + ): + graph = module._build_graph(model) + with TestClient(ResponsesHostServer(graph).app) as client: + conversation = {"id": "hitl-lifecycle"} + + def post(input_items: object) -> dict: + response = client.post( + "/responses", + json={"conversation": conversation, "input": input_items}, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertEqual(payload["status"], "completed", payload) + return payload + + def pending(payload: dict) -> dict: + requests = [ + item + for item in payload["output"] + if item.get("type") == "mcp_approval_request" + ] + self.assertEqual(len(requests), 1, payload) + return requests[0] + + def approve(payload: dict) -> dict: + return post( + [ + { + "type": "mcp_approval_response", + "approval_request_id": pending(payload)["id"], + "approve": True, + } + ] + ) + + def assistant_text(payload: dict) -> str: + return "".join( + part.get("text", "") + for item in payload["output"] + if item.get("type") == "message" + for part in item.get("content", []) + ) + + initial = post("Draft a Seattle launch email.") + pending(initial) + original_call = model.ainvoke.await_args_list[0] + self.assertEqual( + original_call.args[0][1].content, "Draft a Seattle launch email." + ) + sentinels = [ + item + for item in initial["output"] + if item.get("type") == "function_call" + and item.get("name") == "__hosted_agent_adapter_interrupt__" + ] + self.assertEqual(len(sentinels), 1, initial) + feedback = "Keep the Seattle launch date, but shorten the email." + revised = post( + [ + { + "type": "function_call_output", + "call_id": sentinels[0]["call_id"], + "output": json.dumps({"resume": {"feedback": feedback}}), + } + ] + ) + self.assertEqual( + json.loads(pending(revised)["arguments"])["value"]["draft"], + "SEATTLE-DRAFT-REVISED", + ) + self.assertEqual(model.ainvoke.await_count, 2) + revision_messages = model.ainvoke.await_args_list[1].args[0] + self.assertEqual( + [message.content for message in revision_messages[1:]], + [ + "Draft a Seattle launch email.", + "SEATTLE-DRAFT-ORIGINAL", + f"Revision feedback: {feedback}", + ], + "revision must apply the current draft and feedback", + ) + self.assertIsInstance(revision_messages[2], AIMessage) + self.assertIsInstance(revision_messages[3], HumanMessage) + approved = approve(revised) + self.assertFalse( + any( + item.get("type") == "mcp_approval_request" + for item in approved["output"] + ), + approved, + ) + self.assertEqual(model.ainvoke.await_count, 2) + self.assertEqual(assistant_text(approved), "SEATTLE-DRAFT-REVISED") + + following = post("Draft a Paris event invitation.") + self.assertEqual(model.ainvoke.await_count, 3) + following_messages = model.ainvoke.await_args_list[2].args[0] + self.assertEqual( + [message.content for message in following_messages[1:]], + ["Draft a Paris event invitation."], + "new task must exclude the previous draft and revision feedback", + ) + self.assertEqual( + json.loads(pending(following)["arguments"])["value"]["draft"], + "PARIS-DRAFT", + ) + final = approve(following) + self.assertEqual(model.ainvoke.await_count, 3) + self.assertEqual(assistant_text(final), "PARIS-DRAFT") + self.assertFalse( + any( + item.get("type") == "mcp_approval_request" + for item in final["output"] + ), + final, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/hosted-agent-runtime-tests.yml b/.github/workflows/hosted-agent-runtime-tests.yml new file mode 100644 index 000000000..9199abb12 --- /dev/null +++ b/.github/workflows/hosted-agent-runtime-tests.yml @@ -0,0 +1,32 @@ +name: Hosted-agent runtime regressions + +on: + workflow_dispatch: + pull_request: + paths: + - 'samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/src/**' + - '.github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py' + - '.github/workflows/hosted-agent-runtime-tests.yml' + +permissions: + contents: read + +jobs: + hitl-lifecycle: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + RUNTIME_PROJECT: samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/src/langgraph-human-in-the-loop-responses + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.13' + - name: Install runtime test resolver + run: python -m pip install --disable-pip-version-check "uv==0.11.7" + - name: Install frozen sample dependencies + run: uv sync --frozen --python 3.13 --project "$RUNTIME_PROJECT" + - name: Verify HITL revision lifecycle + run: uv run --no-sync --project "$RUNTIME_PROJECT" python .github/scripts/runtime-tests/test_langgraph_sample_lifecycle.py diff --git a/samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/README.md b/samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/README.md index 6aa494564..d4ecfa74e 100644 --- a/samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/README.md +++ b/samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/README.md @@ -7,11 +7,11 @@ A [LangGraph](https://langchain-ai.github.io/langgraph/) **human-in-the-loop** a ### Graph shape ``` -START → draft → await_approval ──approve──► END (emit final draft) - │ - └──revise(feedback)──► draft (loop) - │ - └──reject──► host returns response.failed (interrupt_rejected) +START → start_task → draft → await_approval ──approve──► END (emit final draft) + │ + └──revise(feedback)──► draft (loop) + │ + └──reject──► host returns response.failed (interrupt_rejected) ``` The graph state declares three channels: @@ -20,6 +20,12 @@ The graph state declares three channels: - **`draft`** — the current proposal text under review. - **`revision_history`** — list of `{draft, feedback}` pairs from prior revision rounds; each round seeds the next `draft` call with the rejected draft and the reviewer's feedback. +After completing a task, a new user message in the same conversation starts a new +task. `start_task` clears the previous draft and revision history, while `draft` +selects the latest user message rather than the first conversation message. +Revision feedback resumes the existing task directly and retains its revision +history; it does not pass through `start_task`. + Routing out of `await_approval` is done by returning a [`Command(goto=...)`](https://langchain-ai.github.io/langgraph/how-tos/command/) instead of static edges, so the same node can finalize, loop, or stay paused depending on the resume value. State is persisted by `FoundryCheckpointSaver` and keyed by the `conversation.id` from the Responses request, so follow-up requests continue the paused run even after a process restart. The saver uses Foundry State Store when hosted and a local file-backed store during local development. See [main.py](src/langgraph-human-in-the-loop-responses/main.py) for the full implementation. diff --git a/samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/src/langgraph-human-in-the-loop-responses/main.py b/samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/src/langgraph-human-in-the-loop-responses/main.py index a7391bd15..22fb88690 100644 --- a/samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/src/langgraph-human-in-the-loop-responses/main.py +++ b/samples/python/hosted-agents/langgraph/responses/07-human-in-the-loop/src/langgraph-human-in-the-loop-responses/main.py @@ -92,11 +92,23 @@ class State(TypedDict): def _build_graph(model: ChatOpenAI): + def start_task(state: State) -> dict: + return {"draft": "", "revision_history": []} + async def draft(state: State) -> dict: - task = state["messages"][0].content + task = next( + ( + message + for message in reversed(state["messages"]) + if isinstance(message, HumanMessage) + ), + None, + ) + if task is None: + raise ValueError("A user message is required to draft a proposal") msgs: list[BaseMessage] = [ SystemMessage(content=_DRAFT_PROMPT), - HumanMessage(content=f"Task: {task}"), + HumanMessage(content=task.content), ] for rev in state.get("revision_history", []): msgs.append(AIMessage(content=rev["draft"])) @@ -125,9 +137,11 @@ def await_approval(state: State) -> Command: ) builder = StateGraph(State) + builder.add_node("start_task", start_task) builder.add_node("draft", draft) builder.add_node("await_approval", await_approval) - builder.add_edge(START, "draft") + builder.add_edge(START, "start_task") + builder.add_edge("start_task", "draft") builder.add_edge("draft", "await_approval") checkpointer = FoundryCheckpointSaver(user_isolation=True) return builder.compile(checkpointer=checkpointer)