From abc35f6c25db47956bf9d0b8bf5a28052ee9ec5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:59:53 +0000 Subject: [PATCH 01/16] Bump pydantic-settings from 2.14.0 to 2.14.2 Bumps [pydantic-settings](https://github.com/pydantic/pydantic-settings) from 2.14.0 to 2.14.2. - [Release notes](https://github.com/pydantic/pydantic-settings/releases) - [Commits](https://github.com/pydantic/pydantic-settings/compare/v2.14.0...v2.14.2) --- updated-dependencies: - dependency-name: pydantic-settings dependency-version: 2.14.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 78aab2e3..0f337db8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2164,14 +2164,14 @@ typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" -version = "2.14.0" +version = "2.14.2" description = "Settings management using Pydantic" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e"}, - {file = "pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d"}, + {file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"}, + {file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"}, ] [package.dependencies] From 396a63c21a41300613671fc8a5177f7faeeccc26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:54:43 +0000 Subject: [PATCH 02/16] Bump langsmith from 0.8.1 to 0.8.18 Bumps [langsmith](https://github.com/langchain-ai/langsmith-sdk) from 0.8.1 to 0.8.18. - [Release notes](https://github.com/langchain-ai/langsmith-sdk/releases) - [Commits](https://github.com/langchain-ai/langsmith-sdk/compare/v0.8.1...v0.8.18) --- updated-dependencies: - dependency-name: langsmith dependency-version: 0.8.18 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- poetry.lock | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 78aab2e3..c6d5a528 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1146,14 +1146,14 @@ wrapt = ">=1.14,<2" [[package]] name = "langsmith" -version = "0.8.1" +version = "0.8.18" description = "Client library to connect to the LangSmith Observability and Evaluation Platform." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "langsmith-0.8.1-py3-none-any.whl", hash = "sha256:8809f43d44d53ac3f21127f61fff7f8bbc23e64f164c29d2df8c475ec41be6c3"}, - {file = "langsmith-0.8.1.tar.gz", hash = "sha256:63171ca4fccd6a3209539a7fef4d0e7edc6437d142f6740a6a383bee911bd17e"}, + {file = "langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282"}, + {file = "langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd"}, ] [package.dependencies] @@ -1164,6 +1164,7 @@ pydantic = ">=2,<3" requests = ">=2.0.0" requests-toolbelt = ">=1.0.0" uuid-utils = ">=0.12.0,<1.0" +websockets = ">=15.0" xxhash = ">=3.0.0" zstandard = ">=0.23.0" @@ -1174,7 +1175,6 @@ langsmith-pyo3 = ["langsmith-pyo3 (>=0.1.0rc2)"] openai-agents = ["openai-agents (>=0.0.3)"] otel = ["opentelemetry-api (>=1.30.0)", "opentelemetry-exporter-otlp-proto-http (>=1.30.0)", "opentelemetry-sdk (>=1.30.0)"] pytest = ["pytest (>=7.0.0)", "rich (>=13.9.4)", "vcrpy (>=7.0.0)"] -sandbox = ["websockets (>=15.0)"] strands-agents = ["opentelemetry-api (>=1.30.0)", "opentelemetry-exporter-otlp-proto-http (>=1.30.0)", "opentelemetry-sdk (>=1.30.0)", "strands-agents (>=0.1.0)", "strands-agents-tools (>=0.2.0)"] vcr = ["vcrpy (>=7.0.0)"] @@ -2992,6 +2992,77 @@ files = [ {file = "uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69"}, ] +[[package]] +name = "websockets" +version = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] + [[package]] name = "wrapt" version = "1.17.3" From b51a2f5c2c2bcca1752c16d400036b5901728ca3 Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Wed, 10 Jun 2026 17:44:28 +0900 Subject: [PATCH 03/16] add tests --- services/global_chat/planner.py | 132 ++++++++++++------ services/global_chat/prompts.yaml | 2 +- .../global_chat/tests/unit/test_planner.py | 123 ++++++++++++++++ .../global_chat/tools/tool_definitions.py | 14 +- 4 files changed, 224 insertions(+), 47 deletions(-) create mode 100644 services/global_chat/tests/unit/test_planner.py diff --git a/services/global_chat/planner.py b/services/global_chat/planner.py index 7eb3bd9b..542e336a 100644 --- a/services/global_chat/planner.py +++ b/services/global_chat/planner.py @@ -25,7 +25,7 @@ from global_chat.config_loader import ConfigLoader from models import resolve_model from global_chat.tools.tool_definitions import TOOL_DEFINITIONS -from global_chat.yaml_utils import stitch_job_code, redact_job_bodies, find_job_in_yaml +from global_chat.yaml_utils import stitch_job_code, redact_job_bodies, find_job_in_yaml, get_step_name_from_page from tools.search_documentation.search_documentation import search_documentation_tool from global_chat.subagent_caller import call_workflow_agent, call_job_agent, format_subagent_result_for_llm @@ -110,13 +110,7 @@ def run( messages = history.copy() if history else [] - # Give planner visibility into existing workflow structure (bodies redacted) - user_content = content - if self.current_yaml: - redacted = redact_job_bodies(self.current_yaml) - user_content = f"{content}\n\nExisting workflow structure (job code redacted):\n{redacted}" - - messages.append({"role": "user", "content": user_content}) + messages.append({"role": "user", "content": self._build_user_content(content, page)}) tool_call_count = 0 tool_calls_meta = [] @@ -267,6 +261,27 @@ def run( }, ) + def _build_user_content(self, content: str, page: Optional[str]) -> str: + """Augment the user message with the step the user is viewing ("this step") + and the existing workflow structure (bodies redacted).""" + user_content = content + + if page: + step_name = get_step_name_from_page(page) + if step_name and self.current_yaml: + matched_key, _ = find_job_in_yaml(self.current_yaml, step_name) + step_name = matched_key or step_name + if step_name: + user_content += f"\n\n(The user is currently viewing the step '{step_name}' — \"this step\" refers to it.)" + else: + user_content += f"\n\n(The user is currently viewing: {page})" + + if self.current_yaml: + redacted = redact_job_bodies(self.current_yaml) + user_content += f"\n\nExisting workflow structure (job code redacted):\n{redacted}" + + return user_content + def _call_api(self, system_prompt, messages, stream): """Make Claude API call. When streaming, buffers text deltas for the caller to flush. @@ -327,13 +342,18 @@ def _execute_tool(self, tool_use_block, total_usage, tool_calls_meta) -> str: tool_calls_meta.append({"tool": "search_documentation", "input": tool_use_block.input}) elif tool_use_block.name == "call_workflow_agent": - subagent_result = call_workflow_agent( - tool_use_block.input, - workflow_yaml=self.current_yaml, - api_key=self.api_key, - user=self._user, - metrics_opt_in=self._metrics_opt_in, - ) + try: + subagent_result = call_workflow_agent( + tool_use_block.input, + workflow_yaml=self.current_yaml, + api_key=self.api_key, + user=self._user, + metrics_opt_in=self._metrics_opt_in, + ) + except Exception as e: + logger.exception("call_workflow_agent failed") + tool_calls_meta.append({"tool": "call_workflow_agent", "input": tool_use_block.input, "error": str(e)}) + return f"ERROR: The workflow agent failed: {e}. The workflow was not changed." if "usage" in subagent_result: total_usage.update(sum_usage(total_usage, subagent_result["usage"])) @@ -372,13 +392,18 @@ def _execute_tool(self, tool_use_block, total_usage, tool_calls_meta) -> str: ) return tool_result - subagent_result = call_job_agent( - tool_use_block.input, - workflow_yaml=self.current_yaml, - api_key=self.api_key, - user=self._user, - metrics_opt_in=self._metrics_opt_in, - ) + try: + subagent_result = call_job_agent( + tool_use_block.input, + workflow_yaml=self.current_yaml, + api_key=self.api_key, + user=self._user, + metrics_opt_in=self._metrics_opt_in, + ) + except Exception as e: + logger.exception("call_job_code_agent failed") + tool_calls_meta.append({"tool": "call_job_code_agent", "input": tool_use_block.input, "error": str(e)}) + return f"ERROR: The job code agent failed: {e}. No code was generated for this job." if "usage" in subagent_result: total_usage.update(sum_usage(total_usage, subagent_result["usage"])) @@ -403,15 +428,25 @@ def _execute_tool(self, tool_use_block, total_usage, tool_calls_meta) -> str: tool_calls_meta.append({"tool": "call_job_code_agent", "input": tool_use_block.input}) elif tool_use_block.name == "inspect_job_code": - job_key = tool_use_block.input.get("job_key") + # Accept job_keys (list); tolerate legacy single job_key + job_keys = tool_use_block.input.get("job_keys") or [] + single_key = tool_use_block.input.get("job_key") + if single_key: + job_keys.append(single_key) + if not self.current_yaml: tool_result = "No workflow available to inspect." + elif not job_keys: + tool_result = "ERROR: No job keys provided." else: - _, job_data = find_job_in_yaml(self.current_yaml, job_key) - if job_data and job_data.get("body"): - tool_result = f"Job code for '{job_key}':\n\n{job_data['body']}" - else: - tool_result = f"No code found for job '{job_key}'." + parts = [] + for job_key in job_keys: + _, job_data = find_job_in_yaml(self.current_yaml, job_key) + if job_data and job_data.get("body"): + parts.append(f"Job code for '{job_key}':\n\n{job_data['body']}") + else: + parts.append(f"No code found for job '{job_key}'.") + tool_result = "\n\n".join(parts) tool_calls_meta.append({"tool": "inspect_job_code", "input": tool_use_block.input}) @@ -476,16 +511,24 @@ def _execute_job_code_tools_parallel(self, blocks, stream_manager, total_usage, } for future in as_completed(futures): block = futures[future] - parallel_results[block.id] = future.result() + try: + parallel_results[block.id] = future.result() + except Exception as e: + logger.exception("call_job_code_agent failed") + parallel_results[block.id] = {"_error": str(e)} elif to_run: block = to_run[0] - parallel_results[block.id] = call_job_agent( - block.input, - self.current_yaml, - self.api_key, - self._user, - self._metrics_opt_in, - ) + try: + parallel_results[block.id] = call_job_agent( + block.input, + self.current_yaml, + self.api_key, + self._user, + self._metrics_opt_in, + ) + except Exception as e: + logger.exception("call_job_code_agent failed") + parallel_results[block.id] = {"_error": str(e)} # Stitch results and update state sequentially tool_results = [] @@ -497,6 +540,14 @@ def _execute_job_code_tools_parallel(self, blocks, stream_manager, total_usage, continue subagent_result = parallel_results[block.id] + if "_error" in subagent_result: + tool_calls_meta.append({"tool": "call_job_code_agent", "input": block.input, "error": subagent_result["_error"]}) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": f"ERROR: The job code agent failed: {subagent_result['_error']}. No code was generated for this job.", + }) + continue matched_job_key = matched_keys.get(block.id) if "usage" in subagent_result: @@ -546,10 +597,11 @@ def _tool_status_message(self, tool_use_block) -> str: return "Writing job code..." if name == "inspect_job_code": - job_key = inputs.get("job_key") - display_name = self._display_name_for_job(job_key) - if display_name: - return f"Reading code for \"{display_name}\"..." + job_keys = inputs.get("job_keys") or ([inputs["job_key"]] if inputs.get("job_key") else []) + display_names = [n for n in (self._display_name_for_job(k) for k in job_keys) if n] + if display_names: + joined = ", ".join(f"\"{n}\"" for n in display_names) + return f"Reading code for {joined}..." return "Reading job code..." return f"Running {name}..." diff --git a/services/global_chat/prompts.yaml b/services/global_chat/prompts.yaml index 07833ef9..11d7443c 100644 --- a/services/global_chat/prompts.yaml +++ b/services/global_chat/prompts.yaml @@ -76,7 +76,7 @@ prompts: 1. **search_documentation**: Find information about OpenFn features, adaptors, concepts 2. **call_workflow_agent**: Create/modify workflows (YAML structure) 3. **call_job_code_agent**: Write or edit job code (JavaScript expressions, adaptor functions) - 4. **inspect_job_code**: Read the current code body of a specific job (read-only) + 4. **inspect_job_code**: Read the current code body of one or more jobs (read-only). Pass all job keys you need in one call. ## Your Role diff --git a/services/global_chat/tests/unit/test_planner.py b/services/global_chat/tests/unit/test_planner.py new file mode 100644 index 00000000..4c839a3b --- /dev/null +++ b/services/global_chat/tests/unit/test_planner.py @@ -0,0 +1,123 @@ +"""Unit tests for PlannerAgent tool execution and user-content building.""" + +from unittest.mock import patch + +from global_chat.planner import PlannerAgent + +WORKFLOW_YAML = """\ +name: wf +jobs: + fetch-patients: + name: Fetch Patients + body: get('/patients'); + load-dhis2: + name: Load to DHIS2 + body: '// Add operations here' +""" + + +def make_planner() -> PlannerAgent: + """Build a PlannerAgent without config or an Anthropic client.""" + planner = PlannerAgent.__new__(PlannerAgent) + planner.current_yaml = WORKFLOW_YAML + planner.yaml_modified = False + planner.subagent_results = [] + planner.api_key = "test-key" + planner._user = None + planner._metrics_opt_in = None + return planner + + +def empty_usage() -> dict: + return { + "input_tokens": 0, + "output_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + } + + +class FakeToolUse: + def __init__(self, name: str, tool_input: dict, block_id: str = "tu_1"): + self.name = name + self.input = tool_input + self.id = block_id + + +class StubStreamManager: + def send_thinking(self, *_args: object, **_kwargs: object) -> None: + pass + + +def test_inspect_job_code_accepts_multiple_keys() -> None: + planner = make_planner() + block = FakeToolUse("inspect_job_code", {"job_keys": ["fetch-patients", "missing-step"]}) + + result = planner._execute_tool(block, empty_usage(), []) + + assert "get('/patients');" in result + assert "No code found for job 'missing-step'" in result + + +def test_job_agent_failure_returns_error_tool_result() -> None: + planner = make_planner() + block = FakeToolUse("call_job_code_agent", {"message": "write code", "job_key": "fetch-patients"}) + meta = [] + + with patch("global_chat.planner.call_job_agent", side_effect=RuntimeError("boom")): + result = planner._execute_tool(block, empty_usage(), meta) + + assert result.startswith("ERROR: The job code agent failed: boom") + assert meta[0]["error"] == "boom" + + +def test_workflow_agent_failure_returns_error_tool_result() -> None: + planner = make_planner() + block = FakeToolUse("call_workflow_agent", {"message": "add a step"}) + + with patch("global_chat.planner.call_workflow_agent", side_effect=RuntimeError("boom")): + result = planner._execute_tool(block, empty_usage(), []) + + assert result.startswith("ERROR: The workflow agent failed: boom") + assert planner.current_yaml == WORKFLOW_YAML + assert planner.yaml_modified is False + + +def test_parallel_job_agent_failure_keeps_sibling_results() -> None: + planner = make_planner() + blocks = [ + FakeToolUse("call_job_code_agent", {"message": "m", "job_key": "fetch-patients"}, block_id="tu_ok"), + FakeToolUse("call_job_code_agent", {"message": "m", "job_key": "load-dhis2"}, block_id="tu_bad"), + ] + + def fake_call_job_agent(tool_input: dict, *_args: object, **_kwargs: object) -> dict: + if tool_input["job_key"] == "load-dhis2": + raise RuntimeError("boom") + return {"response": "done", "suggested_code": "newCode();", "usage": empty_usage()} + + with patch("global_chat.planner.call_job_agent", side_effect=fake_call_job_agent): + results = planner._execute_job_code_tools_parallel(blocks, StubStreamManager(), empty_usage(), []) + + by_id = {r["tool_use_id"]: r["content"] for r in results} + assert "stitched into the workflow" in by_id["tu_ok"] + assert by_id["tu_bad"].startswith("ERROR: The job code agent failed: boom") + assert "newCode();" in planner.current_yaml + assert planner.yaml_modified is True + + +def test_user_content_names_the_step_being_viewed() -> None: + planner = make_planner() + + user_content = planner._build_user_content("fix this step", "workflows/my-wf/fetch-patients") + + assert "currently viewing the step 'fetch-patients'" in user_content + assert "Existing workflow structure" in user_content + + +def test_user_content_falls_back_to_page_for_non_step_pages() -> None: + planner = make_planner() + + user_content = planner._build_user_content("rename the workflow", "workflows/my-wf/settings") + + assert "workflows/my-wf/settings" in user_content + assert "currently viewing the step" not in user_content diff --git a/services/global_chat/tools/tool_definitions.py b/services/global_chat/tools/tool_definitions.py index 3c05a5ce..9b17a10b 100644 --- a/services/global_chat/tools/tool_definitions.py +++ b/services/global_chat/tools/tool_definitions.py @@ -82,19 +82,21 @@ # Tool 4: Inspect job code INSPECT_JOB_CODE_TOOL = { "name": "inspect_job_code", - "description": """Read the current code body of a specific job in the workflow (read-only). + "description": """Read the current code body of one or more jobs in the workflow (read-only). Use this when you need to see existing job code before writing code for another job, -for example when the user asks to make one step similar to another.""", +for example when the user asks to make one step similar to another. Pass all the +job keys you need in a single call rather than calling once per job.""", "input_schema": { "type": "object", "properties": { - "job_key": { - "type": "string", - "description": "The job key to inspect (e.g. 'fetch-patients')" + "job_keys": { + "type": "array", + "items": {"type": "string"}, + "description": "The job keys to inspect (e.g. ['fetch-patients', 'load-dhis2'])" } }, - "required": ["job_key"] + "required": ["job_keys"] } } From 2a5903c9cef4e44cc67a7ba2fb21d1c21bd869ec Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Thu, 11 Jun 2026 18:32:26 +0900 Subject: [PATCH 04/16] acurate empty attachment statuses --- services/global_chat/PAYLOAD_SPEC.md | 2 +- services/global_chat/planner.py | 23 +++++++-- services/global_chat/router.py | 22 ++++---- .../global_chat/tests/unit/test_planner.py | 43 ++++++++++++++++ .../global_chat/tests/unit/test_router.py | 51 +++++++++++++++++++ 5 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 services/global_chat/tests/unit/test_router.py diff --git a/services/global_chat/PAYLOAD_SPEC.md b/services/global_chat/PAYLOAD_SPEC.md index 1329a8ef..b8ec9846 100644 --- a/services/global_chat/PAYLOAD_SPEC.md +++ b/services/global_chat/PAYLOAD_SPEC.md @@ -127,7 +127,7 @@ This document defines the input and output payload structure for the Global Agen - **`response`** (string): The main text response from the agent. -- **`attachments`** (array): Artifacts produced during this turn. Each entry has a `type` and `content` field. An empty list `[]` means no artifacts were produced (e.g. a purely informational response). Currently supported types: `workflow_yaml`, `job_code`. When both are present, `job_code` contains the suggested code for a specific job and `workflow_yaml` contains the full YAML with the code stitched in. +- **`attachments`** (array): Artifacts produced during this turn. Each entry has a `type` and `content` field. An empty list `[]` means no artifacts were produced (e.g. a purely informational response). The only supported type is `workflow_yaml`: the full workflow YAML with any job code changes stitched in. Job code edits are never returned separately — the YAML is the single source of truth, which allows multi-step changes in one response. - **`history`** (array): Updated conversation history including the latest exchange. On direct routes (workflow_agent, job_code_agent), each entry has `content` as a string. On the planner path, entries may have `content` as an array of content blocks (`text`, `tool_use`, `tool_result`) — this is the raw Anthropic messages format from the tool-calling loop. diff --git a/services/global_chat/planner.py b/services/global_chat/planner.py index 542e336a..99f23090 100644 --- a/services/global_chat/planner.py +++ b/services/global_chat/planner.py @@ -96,7 +96,7 @@ def run( logger.info("Planner.run() called") stream_manager = StreamManager(model=self.model, stream=stream) - if self.current_yaml: + if workflow_yaml: stream_manager.send_thinking(STATUS_REVIEWING_WORKFLOW + STATUS_PLANNING) else: stream_manager.send_thinking(STATUS_NEW_WORKFLOW + STATUS_PLANNING) @@ -367,10 +367,15 @@ def _execute_tool(self, tool_use_block, total_usage, tool_calls_meta) -> str: tool_result = format_subagent_result_for_llm(subagent_result) - # Give planner a fresh structural view after each workflow change - if self.current_yaml: + # Give planner a fresh structural view after each workflow change. + # If no YAML came back, nothing changed — say so instead of re-sending + # the unchanged structure (a conversational reply or a failed YAML + # parse would otherwise read as a successful edit). + if subagent_result.get("response_yaml"): redacted = redact_job_bodies(self.current_yaml) tool_result += f"\n\nUpdated workflow structure:\n{redacted}" + else: + tool_result += "\n\n[No workflow changes were made — no YAML was produced.]" tool_calls_meta.append({"tool": "call_workflow_agent", "input": tool_use_block.input}) @@ -413,15 +418,19 @@ def _execute_tool(self, tool_use_block, total_usage, tool_calls_meta) -> str: # (case, hyphens vs underscores, or the job's name field), and # stitch_job_code does an exact key match. suggested_code = subagent_result.get("suggested_code") + stitched = False if matched_job_key and suggested_code and self.current_yaml: self.current_yaml = stitch_job_code(self.current_yaml, matched_job_key, suggested_code) self.yaml_modified = True + stitched = True logger.info(f"Stitched code for job '{matched_job_key}' into current_yaml") self.subagent_results.append(subagent_result) tool_result = format_subagent_result_for_llm(subagent_result) - if suggested_code: + if stitched: tool_result += "\n\n[Job code generated and stitched into the workflow.]" + elif suggested_code: + tool_result += "\n\n[Job code was generated but NOT added to the workflow — no job_key matched. Retry with the exact job key.]" else: tool_result += "\n\n[No job code was generated.]" @@ -554,15 +563,19 @@ def _execute_job_code_tools_parallel(self, blocks, stream_manager, total_usage, total_usage.update(sum_usage(total_usage, subagent_result["usage"])) suggested_code = subagent_result.get("suggested_code") + stitched = False if matched_job_key and suggested_code and self.current_yaml: self.current_yaml = stitch_job_code(self.current_yaml, matched_job_key, suggested_code) self.yaml_modified = True + stitched = True logger.info(f"Stitched code for job '{matched_job_key}' into current_yaml") self.subagent_results.append(subagent_result) tool_result = format_subagent_result_for_llm(subagent_result) - if suggested_code: + if stitched: tool_result += "\n\n[Job code generated and stitched into the workflow.]" + elif suggested_code: + tool_result += "\n\n[Job code was generated but NOT added to the workflow — no job_key matched. Retry with the exact job key.]" else: tool_result += "\n\n[No job code was generated.]" diff --git a/services/global_chat/router.py b/services/global_chat/router.py index cdbeb2e2..41aa0e4b 100644 --- a/services/global_chat/router.py +++ b/services/global_chat/router.py @@ -324,21 +324,17 @@ def _route_to_job_chat( result = job_chat_main(payload) total_usage = sum_usage(self.routing_usage, result["usage"]) - # Stitch suggested_code back into workflow YAML - updated_yaml = None - if result.get("suggested_code") and workflow_yaml and matched_job_key: - updated_yaml = stitch_job_code(workflow_yaml, matched_job_key, result["suggested_code"]) - elif result.get("suggested_code") and not matched_job_key: - logger.warning(f"suggested_code generated but no job matched for page '{page}' - YAML not updated") - + # Stitch suggested_code back into the workflow YAML. The full YAML is + # the only artifact returned — no separate job_code attachment. attachments = [] if result.get("suggested_code"): - job_code_attachment = {"type": "job_code", "content": result["suggested_code"]} - if matched_job_key: - job_code_attachment["job_key"] = matched_job_key - attachments.append(job_code_attachment) - if updated_yaml: - attachments.append({"type": "workflow_yaml", "content": updated_yaml}) + if workflow_yaml and matched_job_key: + updated_yaml = stitch_job_code(workflow_yaml, matched_job_key, result["suggested_code"]) + attachments.append({"type": "workflow_yaml", "content": updated_yaml}) + else: + logger.warning( + f"suggested_code generated but no job matched for page '{page}' - code dropped from response" + ) return RouterResult( response=result["response"], diff --git a/services/global_chat/tests/unit/test_planner.py b/services/global_chat/tests/unit/test_planner.py index 4c839a3b..f5f769ef 100644 --- a/services/global_chat/tests/unit/test_planner.py +++ b/services/global_chat/tests/unit/test_planner.py @@ -83,6 +83,49 @@ def test_workflow_agent_failure_returns_error_tool_result() -> None: assert planner.yaml_modified is False +def test_job_code_without_matched_key_is_reported_as_not_stitched() -> None: + planner = make_planner() + block = FakeToolUse("call_job_code_agent", {"message": "write code"}) # no job_key + subagent_result = {"response": "done", "suggested_code": "newCode();", "usage": empty_usage()} + + with patch("global_chat.planner.call_job_agent", return_value=subagent_result): + result = planner._execute_tool(block, empty_usage(), []) + + assert "NOT added to the workflow" in result + assert "stitched into the workflow" not in result + assert planner.current_yaml == WORKFLOW_YAML + assert planner.yaml_modified is False + + +def test_workflow_agent_yaml_response_updates_structure_view() -> None: + planner = make_planner() + block = FakeToolUse("call_workflow_agent", {"message": "add a step"}) + new_yaml = WORKFLOW_YAML + " new-step:\n name: New Step\n body: '// Add operations here'\n" + subagent_result = {"response": "Added the step.", "response_yaml": new_yaml, "usage": empty_usage()} + + with patch("global_chat.planner.call_workflow_agent", return_value=subagent_result): + result = planner._execute_tool(block, empty_usage(), []) + + assert "Updated workflow structure:" in result + assert "new-step" in result + assert planner.current_yaml == new_yaml + assert planner.yaml_modified is True + + +def test_workflow_agent_without_yaml_reports_no_change() -> None: + planner = make_planner() + block = FakeToolUse("call_workflow_agent", {"message": "add a step"}) + subagent_result = {"response": "Which DHIS2 instance?", "response_yaml": None, "usage": empty_usage()} + + with patch("global_chat.planner.call_workflow_agent", return_value=subagent_result): + result = planner._execute_tool(block, empty_usage(), []) + + assert "[No workflow changes were made — no YAML was produced.]" in result + assert "Updated workflow structure:" not in result + assert planner.current_yaml == WORKFLOW_YAML + assert planner.yaml_modified is False + + def test_parallel_job_agent_failure_keeps_sibling_results() -> None: planner = make_planner() blocks = [ diff --git a/services/global_chat/tests/unit/test_router.py b/services/global_chat/tests/unit/test_router.py new file mode 100644 index 00000000..25e3a8aa --- /dev/null +++ b/services/global_chat/tests/unit/test_router.py @@ -0,0 +1,51 @@ +"""Unit tests for RouterAgent attachment building on the direct job_chat route.""" + +from unittest.mock import patch + +from global_chat.router import RouterAgent + +WORKFLOW_YAML = """\ +name: wf +jobs: + fetch-patients: + name: Fetch Patients + body: get('/patients'); +""" + + +def make_router() -> RouterAgent: + """Build a RouterAgent without config or an Anthropic client.""" + router = RouterAgent.__new__(RouterAgent) + router.api_key = "test-key" + router.routing_usage = {} + router._input_attachments = [] + router._user = None + router._metrics_opt_in = None + return router + + +def job_chat_result(suggested_code: str | None) -> dict: + return {"response": "done", "suggested_code": suggested_code, "history": [], "usage": {}} + + +def test_job_route_returns_only_full_yaml_attachment() -> None: + router = make_router() + + with patch("job_chat.job_chat.main", return_value=job_chat_result("newCode();")): + result = router._route_to_job_chat( + "edit this", WORKFLOW_YAML, "workflows/wf/fetch-patients", [], False, 5, + ) + + assert [a["type"] for a in result.attachments] == ["workflow_yaml"] + assert "newCode();" in result.attachments[0]["content"] + + +def test_job_route_with_unmatched_job_returns_no_attachments() -> None: + router = make_router() + + with patch("job_chat.job_chat.main", return_value=job_chat_result("newCode();")): + result = router._route_to_job_chat( + "edit this", WORKFLOW_YAML, "workflows/wf/settings", [], False, 5, router_job_key="nonexistent", + ) + + assert result.attachments == [] From ce39a7a67db138e3485521b251a7e4d15e63e5cd Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Mon, 15 Jun 2026 17:11:08 +0900 Subject: [PATCH 05/16] logs --- services/global_chat/router.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/services/global_chat/router.py b/services/global_chat/router.py index 41aa0e4b..e560c6ed 100644 --- a/services/global_chat/router.py +++ b/services/global_chat/router.py @@ -6,6 +6,7 @@ import os import json +import yaml from typing import List, Dict, Optional from dataclasses import dataclass from anthropic import Anthropic @@ -295,8 +296,25 @@ def _route_to_job_chat( matched_job_key, job_data = find_job_in_yaml(workflow_yaml, page_step) if matched_job_key is None: + # Distinguish the failure modes so the cause is visible in logs: + # no YAML sent vs. a YAML shape we can't read (e.g. Lightning's + # project format nests jobs under `workflows:` rather than a + # top-level `jobs:`) vs. YAML present but the job name not found. + if not workflow_yaml: + reason = "no workflow_yaml was provided in the request" + else: + try: + parsed = yaml.safe_load(workflow_yaml) + if not isinstance(parsed, dict): + reason = "workflow_yaml did not parse to a mapping" + elif "jobs" not in parsed: + reason = f"workflow_yaml has no top-level 'jobs' key (top-level keys: {list(parsed.keys())})" + else: + reason = f"job not found among keys {list(parsed['jobs'].keys())}" + except Exception as e: + reason = f"workflow_yaml failed to parse: {e}" logger.warning( - f"No job found in YAML for router_job_key='{router_job_key}' or page='{page}'", + f"No job matched for router_job_key='{router_job_key}' or page='{page}': {reason}" ) if job_data: From 64c35a076c4610abbb972e20efd6e8177f55fb13 Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Mon, 22 Jun 2026 16:53:34 +0100 Subject: [PATCH 06/16] make yaml edit priority explicit --- services/global_chat/planner.py | 50 ++++++++++++------- .../global_chat/tests/unit/test_planner.py | 35 +++++++++++++ 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/services/global_chat/planner.py b/services/global_chat/planner.py index 99f23090..583c358e 100644 --- a/services/global_chat/planner.py +++ b/services/global_chat/planner.py @@ -161,23 +161,9 @@ def run( logger.info(f"Executing {len(tool_use_blocks)} tool(s): {[b.name for b in tool_use_blocks]}") - job_code_blocks = [b for b in tool_use_blocks if b.name == "call_job_code_agent"] - other_blocks = [b for b in tool_use_blocks if b.name != "call_job_code_agent"] - - tool_results = [] - - for tool_use_block in other_blocks: - stream_manager.send_thinking(self._tool_status_message(tool_use_block)) - tool_result = self._execute_tool(tool_use_block, total_usage, tool_calls_meta) - tool_results.append( - {"type": "tool_result", "tool_use_id": tool_use_block.id, "content": tool_result} - ) - - if job_code_blocks: - job_results = self._execute_job_code_tools_parallel( - job_code_blocks, stream_manager, total_usage, tool_calls_meta - ) - tool_results.extend(job_results) + tool_results = self._execute_tool_blocks( + tool_use_blocks, stream_manager, total_usage, tool_calls_meta + ) content_blocks = [] for block in response.content: @@ -465,6 +451,36 @@ def _execute_tool(self, tool_use_block, total_usage, tool_calls_meta) -> str: return tool_result + def _execute_tool_blocks(self, tool_use_blocks, stream_manager, total_usage, tool_calls_meta): + """Run a batch of tool_use blocks in a deliberate order and collect results. + + Ordering is load-bearing: workflow-structure tools (and any other + non-job tools) run FIRST and mutate ``self.current_yaml``, then the + job-code tools run against that updated YAML. The prompt tells the + planner never to mix call_workflow_agent and call_job_code_agent in one + step, but if it does anyway, this order is what keeps job code stitched + into the freshly-modified workflow rather than a stale snapshot. + """ + job_code_blocks = [b for b in tool_use_blocks if b.name == "call_job_code_agent"] + other_blocks = [b for b in tool_use_blocks if b.name != "call_job_code_agent"] + + tool_results = [] + + for tool_use_block in other_blocks: + stream_manager.send_thinking(self._tool_status_message(tool_use_block)) + tool_result = self._execute_tool(tool_use_block, total_usage, tool_calls_meta) + tool_results.append( + {"type": "tool_result", "tool_use_id": tool_use_block.id, "content": tool_result} + ) + + if job_code_blocks: + job_results = self._execute_job_code_tools_parallel( + job_code_blocks, stream_manager, total_usage, tool_calls_meta + ) + tool_results.extend(job_results) + + return tool_results + def _execute_job_code_tools_parallel(self, blocks, stream_manager, total_usage, tool_calls_meta): """Execute multiple call_job_code_agent tools with parallel API calls. diff --git a/services/global_chat/tests/unit/test_planner.py b/services/global_chat/tests/unit/test_planner.py index f5f769ef..469e10b3 100644 --- a/services/global_chat/tests/unit/test_planner.py +++ b/services/global_chat/tests/unit/test_planner.py @@ -148,6 +148,41 @@ def fake_call_job_agent(tool_input: dict, *_args: object, **_kwargs: object) -> assert planner.yaml_modified is True +def test_tool_blocks_run_workflow_before_job_against_updated_yaml() -> None: + """Workflow tools must run first and mutate the YAML before job tools run. + + The job below targets a step that only exists AFTER the workflow agent + runs, so it can only be matched/stitched if it sees the updated YAML. + """ + planner = make_planner() + new_yaml = WORKFLOW_YAML + " new-step:\n name: New Step\n body: '// Add operations here'\n" + call_order = [] + + def fake_call_workflow_agent(*_args: object, **_kwargs: object) -> dict: + call_order.append("workflow") + return {"response": "Added the step.", "response_yaml": new_yaml, "usage": empty_usage()} + + def fake_call_job_agent(_tool_input: dict, workflow_yaml: str, *_args: object, **_kwargs: object) -> dict: + call_order.append("job") + # Proves the job agent saw the post-workflow YAML, not the snapshot. + assert "new-step" in workflow_yaml + return {"response": "done", "suggested_code": "newCode();", "usage": empty_usage()} + + blocks = [ + FakeToolUse("call_job_code_agent", {"message": "m", "job_key": "new-step"}, block_id="tu_job"), + FakeToolUse("call_workflow_agent", {"message": "add a step"}, block_id="tu_wf"), + ] + + with patch("global_chat.planner.call_workflow_agent", side_effect=fake_call_workflow_agent), \ + patch("global_chat.planner.call_job_agent", side_effect=fake_call_job_agent): + results = planner._execute_tool_blocks(blocks, StubStreamManager(), empty_usage(), []) + + assert call_order == ["workflow", "job"] + by_id = {r["tool_use_id"]: r["content"] for r in results} + assert "stitched into the workflow" in by_id["tu_job"] + assert "newCode();" in planner.current_yaml + + def test_user_content_names_the_step_being_viewed() -> None: planner = make_planner() From a50294ab19fcc5e76c5c0e78cc800e0c45618b82 Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Mon, 22 Jun 2026 21:10:34 +0100 Subject: [PATCH 07/16] clarify workflow subagent role --- services/global_chat/prompts.yaml | 6 ++--- .../global_chat/tools/tool_definitions.py | 26 +++++++------------ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/services/global_chat/prompts.yaml b/services/global_chat/prompts.yaml index 11d7443c..e945cca6 100644 --- a/services/global_chat/prompts.yaml +++ b/services/global_chat/prompts.yaml @@ -96,9 +96,9 @@ prompts: ## Guidelines ### Tool Selection - - Workflow structure/triggers/flow → call_workflow_agent - - Job code/adaptor functions/expressions → call_job_code_agent - - Read existing job code (e.g. to base another job on it) → inspect_job_code + - Add, remove, rename, or reorder steps; change adaptors or triggers; edit the flow/edges between steps → call_workflow_agent + - Code inside a step's body — writing, editing, or applying a change across one or many steps → call_job_code_agent, one call per step. call_workflow_agent CANNOT touch step code. + - Read existing job code (e.g. to see which steps a change applies to, or to base one step on another) → inspect_job_code - General concepts/documentation → search_documentation ### Tool Call Ordering diff --git a/services/global_chat/tools/tool_definitions.py b/services/global_chat/tools/tool_definitions.py index 9b17a10b..6f94fa7b 100644 --- a/services/global_chat/tools/tool_definitions.py +++ b/services/global_chat/tools/tool_definitions.py @@ -29,19 +29,13 @@ # Tool 2: Call workflow agent CALL_WORKFLOW_AGENT_TOOL = { "name": "call_workflow_agent", - "description": """Create or modify OpenFn workflows (YAML). + "description": """Create or modify workflow STRUCTURE (YAML): which steps/jobs exist, their names, adaptors, triggers, and edges (the flow between steps). -Use this tool when the user wants to: -- Do anything related to workflows -- Add jobs, steps, or triggers to an existing workflow or create a new workflow from scratch -- Modify workflow structure or configuration -- Debug or fix workflow YAML errors +Use for: adding, removing, renaming, or reordering steps; changing adaptors or triggers; editing the flow/edges between steps; fixing YAML structure errors. -Write a clear message for the workflow_agent. Include any relevant conversation -context that the agent needs to understand the request. +CANNOT read or edit the code inside a step (its `body` / expression). To write or change step code — even the same change across many steps — use call_job_code_agent, never this tool. -The current workflow YAML is automatically passed to the workflow_agent. -Do NOT include YAML in your message.""", +The current workflow YAML is passed automatically. Do NOT include YAML in your message.""", "input_schema": { "type": "object", "properties": { @@ -57,11 +51,11 @@ # Tool 3: Call job code agent CALL_JOB_CODE_AGENT_TOOL = { "name": "call_job_code_agent", - "description": """Get help with OpenFn job code (JavaScript expressions for individual steps). -Requires a workflow YAML with the target job_key already defined — call call_workflow_agent first. + "description": """Write or edit the code inside a step (its JavaScript `body` / adaptor expression). This is the ONLY tool that can read or change step code. -Use this tool for writing or debugging job expressions. Describe the goal; the job code agent -is the expert on adaptor functions and will choose the right implementation.""", +Edits ONE step per call — set job_key to that step. To make a code change across N steps, make N calls (they may run in parallel). The step must already exist in the workflow YAML — call call_workflow_agent first if it doesn't. + +Describe the goal in plain language; the job code agent is the expert on adaptor functions and will choose the implementation.""", "input_schema": { "type": "object", "properties": { @@ -84,9 +78,7 @@ "name": "inspect_job_code", "description": """Read the current code body of one or more jobs in the workflow (read-only). -Use this when you need to see existing job code before writing code for another job, -for example when the user asks to make one step similar to another. Pass all the -job keys you need in a single call rather than calling once per job.""", +Use this to inspect existing step code before editing — e.g. to find which steps a change applies to before editing only those, or to base one step on another. Pass all the job keys you need in a single call rather than calling once per job.""", "input_schema": { "type": "object", "properties": { From 7ded31a36071eabeefba3525b4fb0e998bc1d5c1 Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Tue, 23 Jun 2026 14:47:53 +0100 Subject: [PATCH 08/16] fix adaptor selection delegation --- services/global_chat/prompts.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/services/global_chat/prompts.yaml b/services/global_chat/prompts.yaml index e945cca6..f6ab9b9f 100644 --- a/services/global_chat/prompts.yaml +++ b/services/global_chat/prompts.yaml @@ -47,7 +47,7 @@ prompts: Your primary task is to leverage the tools available to you to help the user with their request. Before using your tools, assess whether you have enough information to build the - workflow structure (jobs, triggers, edges, and which adaptors to use). + workflow structure (which systems are involved, the steps, triggers, and flow). If you can determine the overall workflow shape — which systems are involved and the direction of data flow — go ahead and build it: @@ -112,6 +112,10 @@ prompts: You may call `call_job_code_agent` for multiple existing jobs in parallel. Never call `call_job_code_agent` and `call_workflow_agent` in the same step. + For each `call_workflow_agent` call: + - Name the systems to connect in plain language (e.g. "fetch from Salesforce, post to Slack"). + The workflow agent is the expert on which adaptor fits — never name an adaptor yourself. + For each `call_job_code_agent` call: - Set `message` to describe the goal in plain language (e.g. "fetch patient cases from CommCare"). The job code agent is the expert — avoid suggesting specific function names unless relevant. From c1a1d661890b9af3bc0a73c6295d0a4a700666c4 Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Wed, 24 Jun 2026 16:01:53 +0100 Subject: [PATCH 09/16] add step instructions --- services/global_chat/prompts.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/global_chat/prompts.yaml b/services/global_chat/prompts.yaml index f6ab9b9f..4bef069d 100644 --- a/services/global_chat/prompts.yaml +++ b/services/global_chat/prompts.yaml @@ -17,7 +17,7 @@ prompts: Context clues: - Page URL ends with a step name (`workflows/x/step-name`) → likely job_code_agent, BUT only if the request is about that one step - Page URL is workflow overview or settings (`workflows/x`, `workflows/x/settings`) → likely workflow_agent - - YAML present + user asks about structure, triggers, or flow → workflow_agent + - YAML present + structural change (rename, reorder, delete, edges/triggers, or add an empty step) → workflow_agent - No YAML + user wants a new workflow → planner (needs structure then code) Response format (JSON only, no explanation): @@ -35,6 +35,7 @@ prompts: - The request involves more than one job code step (e.g. "all steps", "both", "every", "the other step too") - The request requires comparing or examining multiple steps - Needs both workflow AND job code + - Adding a step that needs code (does something) — workflow_agent only creates an empty step; planner adds it then writes the code - Multiple sequential operations - Unclear/ambiguous request - No existing YAML and user asks for a workflow (building from scratch always needs planner) @@ -111,6 +112,7 @@ prompts: You may call `call_job_code_agent` for multiple existing jobs in parallel. Never call `call_job_code_agent` and `call_workflow_agent` in the same step. + If you can't say what a step's code should do, add the step only and ask the user — don't call the job code agent with a vague goal. For each `call_workflow_agent` call: - Name the systems to connect in plain language (e.g. "fetch from Salesforce, post to Slack"). From a8230c9feac15cf624d56296f2ae9f6f76717224 Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Wed, 24 Jun 2026 16:53:53 +0100 Subject: [PATCH 10/16] clarify instructions for basic questions --- services/global_chat/config.yaml | 2 +- services/global_chat/planner.py | 2 +- services/global_chat/prompts.yaml | 8 ++-- services/global_chat/router.py | 6 ++- .../global_chat/tests/unit/test_router.py | 38 +++++++++++++++++++ services/global_chat/yaml_utils.py | 24 ++++++++++++ 6 files changed, 72 insertions(+), 8 deletions(-) diff --git a/services/global_chat/config.yaml b/services/global_chat/config.yaml index a8714f58..89565064 100644 --- a/services/global_chat/config.yaml +++ b/services/global_chat/config.yaml @@ -11,4 +11,4 @@ planner: model: "claude-sonnet" max_tokens: 8192 temperature: 1.0 - max_tool_calls: 10 + max_tool_calls: 25 diff --git a/services/global_chat/planner.py b/services/global_chat/planner.py index 583c358e..4a6ea01b 100644 --- a/services/global_chat/planner.py +++ b/services/global_chat/planner.py @@ -62,7 +62,7 @@ def __init__(self, config_loader: ConfigLoader, api_key: Optional[str] = None): self.model = resolve_model(planner_config.get("model", "claude-sonnet")) self.max_tokens = planner_config.get("max_tokens", 8192) self.temperature = planner_config.get("temperature", 1.0) - self.max_tool_calls = planner_config.get("max_tool_calls", 20) + self.max_tool_calls = planner_config.get("max_tool_calls", 25) self.current_yaml: Optional[str] = None self.subagent_results = [] diff --git a/services/global_chat/prompts.yaml b/services/global_chat/prompts.yaml index 4bef069d..692b487b 100644 --- a/services/global_chat/prompts.yaml +++ b/services/global_chat/prompts.yaml @@ -29,11 +29,12 @@ prompts: IMPORTANT: Return ONLY the JSON object above. Do not add any explanation or reasoning. - Route to job_code_agent ONLY when the request is about exactly one job code step. This is a shortcut that skips the planner for simple, single-step edits or questions. + Route to job_code_agent ONLY when the request is about exactly one job code step. This is a shortcut that skips the planner for simple, single-step edits or questions. A request naming two or more steps ("the first two steps", "the last two", "both") is NEVER job_code_agent — route to planner, the only agent that can read or act on multiple steps. Route to planner if: - The request involves more than one job code step (e.g. "all steps", "both", "every", "the other step too") - The request requires comparing or examining multiple steps + - Asks what the workflow or its steps actually do, AND the YAML is tagged [Steps contain job code] — only the planner can read the real code. (If tagged [All step bodies are empty/placeholder], use workflow_agent instead — structure only, and faster.) - Needs both workflow AND job code - Adding a step that needs code (does something) — workflow_agent only creates an empty step; planner adds it then writes the code - Multiple sequential operations @@ -148,9 +149,6 @@ prompts: - DO NOT add reassurances or explanations beyond what the subagent provided - Trust that subagents gave the right amount of information - If a subagent asks "I need to see your code", relay this directly without adding: - - Lists of what you'll do once you see it - - Additional technical details - - Extra reassurances + First, act on the reply: if it shows you used the wrong tool (e.g. it couldn't do what you asked), re-route to the right one rather than relaying it to the user. Only add context if the subagent's response is genuinely unclear or incomplete. diff --git a/services/global_chat/router.py b/services/global_chat/router.py index e560c6ed..238cf4dc 100644 --- a/services/global_chat/router.py +++ b/services/global_chat/router.py @@ -21,7 +21,7 @@ from util import create_logger, ApolloError, sum_usage from global_chat.config_loader import ConfigLoader from models import resolve_model -from global_chat.yaml_utils import get_step_name_from_page, find_job_in_yaml, stitch_job_code +from global_chat.yaml_utils import get_step_name_from_page, find_job_in_yaml, stitch_job_code, workflow_has_job_code logger = create_logger(__name__) @@ -207,6 +207,10 @@ def _build_routing_message( if workflow_yaml: parts.append(f"\n[Workflow YAML attached, length: {len(workflow_yaml)} chars]") + if workflow_has_job_code(workflow_yaml): + parts.append("[Steps contain job code]") + else: + parts.append("[All step bodies are empty/placeholder]") parts.append(f"YAML content:\n{workflow_yaml}") return "\n".join(parts) diff --git a/services/global_chat/tests/unit/test_router.py b/services/global_chat/tests/unit/test_router.py index 25e3a8aa..1a3ada34 100644 --- a/services/global_chat/tests/unit/test_router.py +++ b/services/global_chat/tests/unit/test_router.py @@ -3,6 +3,18 @@ from unittest.mock import patch from global_chat.router import RouterAgent +from global_chat.yaml_utils import workflow_has_job_code + +EMPTY_YAML = """\ +name: wf +jobs: + fetch-patients: + name: Fetch Patients + body: '// Add operations here' + send: + name: Send + body: ' ' +""" WORKFLOW_YAML = """\ name: wf @@ -49,3 +61,29 @@ def test_job_route_with_unmatched_job_returns_no_attachments() -> None: ) assert result.attachments == [] + + +def test_workflow_has_job_code_detects_real_code() -> None: + assert workflow_has_job_code(WORKFLOW_YAML) is True + + +def test_workflow_has_job_code_treats_placeholder_and_blank_as_empty() -> None: + assert workflow_has_job_code(EMPTY_YAML) is False + + +def test_workflow_has_job_code_handles_missing_or_unparseable_yaml() -> None: + assert workflow_has_job_code(None) is False + assert workflow_has_job_code("") is False + assert workflow_has_job_code(": not valid yaml :") is False + + +def test_routing_message_tags_workflow_with_code() -> None: + router = make_router() + msg = router._build_routing_message("what does this do", WORKFLOW_YAML, None, []) + assert "[Steps contain job code]" in msg + + +def test_routing_message_tags_empty_workflow() -> None: + router = make_router() + msg = router._build_routing_message("what does this do", EMPTY_YAML, None, []) + assert "[All step bodies are empty/placeholder]" in msg diff --git a/services/global_chat/yaml_utils.py b/services/global_chat/yaml_utils.py index 17fd0126..119bbcf0 100644 --- a/services/global_chat/yaml_utils.py +++ b/services/global_chat/yaml_utils.py @@ -68,6 +68,30 @@ def find_job_in_yaml(yaml_str: str, step_name: str) -> Tuple[Optional[str], Opti return None, None +EMPTY_JOB_BODY = "// Add operations here" + + +def workflow_has_job_code(yaml_str: Optional[str]) -> bool: + """Return True if any job has a non-empty, non-placeholder body. + + The canonical empty-job marker is ``// Add operations here`` (see + workflow_chat); a blank body or that marker means "no code yet". Used to + decide whether a "what does this do" question needs the planner (to read the + real code) or can take the faster workflow_agent path (structure only). + """ + try: + yaml_data = yaml.safe_load(yaml_str) + except Exception: + return False + if not yaml_data or "jobs" not in yaml_data: + return False + for job_data in yaml_data["jobs"].values(): + body = (job_data or {}).get("body") + if isinstance(body, str) and body.strip() and body.strip() != EMPTY_JOB_BODY: + return True + return False + + def redact_job_bodies(yaml_str: str) -> str: """Return workflow YAML with job bodies replaced by a placeholder.""" try: From d9af39a434bf7bce4b959cbe41343f2e5e0aa58c Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Wed, 24 Jun 2026 17:31:20 +0100 Subject: [PATCH 11/16] make history format consistent --- services/global_chat/README.md | 14 ++++++++------ services/global_chat/planner.py | 8 +++++++- services/job_chat/job_chat.py | 2 ++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/services/global_chat/README.md b/services/global_chat/README.md index 88285090..3531303c 100644 --- a/services/global_chat/README.md +++ b/services/global_chat/README.md @@ -109,7 +109,9 @@ Request to build a new multi-step workflow from scratch: - `response`: The assistant's text response to the user - `attachments`: Artifacts produced — currently always `{type: "workflow_yaml", content: string}` when YAML was generated or modified -- `history`: Updated conversation history including the latest exchange +- `history`: Updated conversation history including the latest exchange. Direct + routes return string `content`; the planner path may return `content` as + Anthropic content-block arrays (`tool_use`/`tool_result`) - `usage`: Aggregated token usage across all agents called during the request - `meta.agents`: Ordered list of agents invoked (e.g. `["router", "workflow_agent"]` or @@ -163,18 +165,18 @@ then calls `call_job_code_agent` for each job that needs code. Job code is stitched into the workflow YAML immediately after each call. The loop continues until the model signals it is done (up to a configurable -maximum of tool calls, default 10). +maximum of tool calls, default 25). ## Testing -Run the multi-step planner tests with: +Run the fast unit tests (no LLM calls): ```bash -poetry run pytest global_chat/tests/test_planner_multistep.py -v -s +poetry run pytest services/global_chat/tests/unit/ -q ``` -Run all tests for the service: +Run all tests for the service (some hit live LLM APIs and cost tokens): ```bash -poetry run pytest global_chat/tests/ -v -s +poetry run pytest services/global_chat/tests/ -v -s ``` diff --git a/services/global_chat/planner.py b/services/global_chat/planner.py index 4a6ea01b..6a06e148 100644 --- a/services/global_chat/planner.py +++ b/services/global_chat/planner.py @@ -233,10 +233,16 @@ def run( if self.yaml_modified and self.current_yaml: attachments.append({"type": "workflow_yaml", "content": self.current_yaml}) + # Return string-content history matching the direct routes, not the + # internal block-format messages used by the tool-calling loop. + return_history = (history.copy() if history else []) + return_history.append({"role": "user", "content": content}) + return_history.append({"role": "assistant", "content": final_text}) + return PlannerResult( response=final_text, attachments=attachments, - history=messages, + history=return_history, usage=total_usage, meta={ "agents": agents_used, diff --git a/services/job_chat/job_chat.py b/services/job_chat/job_chat.py index 31c11440..22c30568 100644 --- a/services/job_chat/job_chat.py +++ b/services/job_chat/job_chat.py @@ -90,6 +90,8 @@ def extract_page_prefix_from_last_turn(history: List[Dict[str, str]]) -> Optiona # Second-to-last turn is the last user message content = history[-2].get("content", "") + if not isinstance(content, str): + return None # Extract [pg:...] prefix if present if content.startswith("[pg:") and "]" in content: From 2f868628d186840cd057be1bb7f15a248963a8ed Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Wed, 24 Jun 2026 17:44:25 +0100 Subject: [PATCH 12/16] instruct on job chat limitations --- services/global_chat/prompts.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/global_chat/prompts.yaml b/services/global_chat/prompts.yaml index 692b487b..4da24ee0 100644 --- a/services/global_chat/prompts.yaml +++ b/services/global_chat/prompts.yaml @@ -29,7 +29,7 @@ prompts: IMPORTANT: Return ONLY the JSON object above. Do not add any explanation or reasoning. - Route to job_code_agent ONLY when the request is about exactly one job code step. This is a shortcut that skips the planner for simple, single-step edits or questions. A request naming two or more steps ("the first two steps", "the last two", "both") is NEVER job_code_agent — route to planner, the only agent that can read or act on multiple steps. + Route to job_code_agent ONLY when the request is about exactly one job code step. This is a shortcut that skips the planner for simple, single-step edits or questions. A request naming two or more steps ("the first two steps", "the last two", "both") is NEVER job_code_agent — route to planner, the only agent that can read or act on multiple steps. Likewise, editing one step but using another step as a reference ("make this like my first step", "match the other step's logs", "do the same as elsewhere") needs the planner — job_code_agent sees only the one step and cannot read any other. Route to planner if: - The request involves more than one job code step (e.g. "all steps", "both", "every", "the other step too") From 1e762478ab8ced5cd8a1f9876f33d3a6f7e8d3d7 Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Wed, 24 Jun 2026 17:59:41 +0100 Subject: [PATCH 13/16] add acceptance tests for multi step changes --- .../test_add_specific_step.md | 93 ++++++++++++ .../multi_step_changes/test_add_vague_step.md | 93 ++++++++++++ ...t_change_current_step_using_other_steps.md | 140 ++++++++++++++++++ ...change_different_step_from_another_step.md | 110 ++++++++++++++ .../test_several_changes_across_steps.md | 107 +++++++++++++ 5 files changed, 543 insertions(+) create mode 100644 services/global_chat/tests/acceptance/multi_step_changes/test_add_specific_step.md create mode 100644 services/global_chat/tests/acceptance/multi_step_changes/test_add_vague_step.md create mode 100644 services/global_chat/tests/acceptance/multi_step_changes/test_change_current_step_using_other_steps.md create mode 100644 services/global_chat/tests/acceptance/multi_step_changes/test_change_different_step_from_another_step.md create mode 100644 services/global_chat/tests/acceptance/multi_step_changes/test_several_changes_across_steps.md diff --git a/services/global_chat/tests/acceptance/multi_step_changes/test_add_specific_step.md b/services/global_chat/tests/acceptance/multi_step_changes/test_add_specific_step.md new file mode 100644 index 00000000..6d29d35a --- /dev/null +++ b/services/global_chat/tests/acceptance/multi_step_changes/test_add_specific_step.md @@ -0,0 +1,93 @@ +--- +id: global-chat.multi-step.add-specific-step +service: global_chat +judges: [general, openfn_workflow_expert, openfn_code_quality] +--- + +# notes + +From the workflow overview the user asks to add a new step with a clear, +concrete job: drop any record whose status isn't "active" before it reaches the +database write. Because the request is specific enough to write code against, +the planner should handle it — first calling the workflow agent to insert the +step and rewire the edges (fetch -> new filter step -> db write), then calling +the job code agent to fill the new step's body with the filtering logic. The +final YAML should contain a new step that actually filters on status, wired +between the fetch and write steps. + +# quality_criteria + +- A new step is inserted between fetch-records and write-records and the edges are rewired so data flows fetch -> new step -> write (the original fetch->write edge no longer skips the new step). +- The new step's body contains real filtering logic that keeps only records whose status is "active" (and discards the rest), rather than being left empty. +- The fetch-records body is preserved. (write-records may be adjusted only if it must consume a new state key the filter produces.) + +# settings + +## page + +workflows/intake-to-postgres + +## workflow_yaml + +```yaml +name: intake-to-postgres +jobs: + fetch-records: + id: job-fetch-records-id + name: Fetch Records + adaptor: "@openfn/language-http@7.3.1" + body: | + get('https://intake.example.org/api/records', { + query: { updated_since: $.lastRunAt } + }); + fn(state => { + const records = state.data?.records || []; + return { ...state, records }; + }); + write-records: + id: job-write-records-id + name: Write Records to Postgres + adaptor: "@openfn/language-postgresql@8.1.1" + body: | + each( + $.records, + insert('records', state => ({ + external_id: state.data.id, + status: state.data.status, + payload: JSON.stringify(state.data) + })) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "*/15 * * * *" + enabled: true +edges: + cron->fetch-records: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-records + condition_type: always + enabled: true + fetch-records->write-records: + id: edge-fetch-write + source_job: fetch-records + target_job: write-records + condition_type: on_job_success + enabled: true +``` + +## meta.session_id + +sess-multi-step-add-specific-step-0005 + +# turn + +## role + +user + +## content + +We're getting a load of junk in the database. Can you add a step between fetching and the database write that throws away any record whose status isn't "active"? diff --git a/services/global_chat/tests/acceptance/multi_step_changes/test_add_vague_step.md b/services/global_chat/tests/acceptance/multi_step_changes/test_add_vague_step.md new file mode 100644 index 00000000..5a571207 --- /dev/null +++ b/services/global_chat/tests/acceptance/multi_step_changes/test_add_vague_step.md @@ -0,0 +1,93 @@ +--- +id: global-chat.multi-step.add-vague-step +service: global_chat +judges: [general, openfn_workflow_expert] +--- + +# notes + +From the workflow overview the user asks to add a step but is vague about what +it should actually do ("back up the data somewhere") — no destination, no +format, nothing concrete to write code against. The right move is to add the +step to the structure and wire it into the flow, leaving the body empty or as a +placeholder, then ask the user for the missing detail. It is fine for this to go +straight to the workflow agent (structure only). The assistant should NOT invent +a backup destination or write speculative job code, and should follow up asking +where/how the data should be backed up. + +# quality_criteria + +- A new step is added to the workflow and connected into the flow (placed sensibly at the end, e.g. after post-to-api). +- The new step's body is left empty or the canonical `// Add operations here` placeholder — no concrete backup/archiving code is invented, since the user never said where or how to back up the data. (A reasonable clarifying question instead of a guessed destination also satisfies this.) +- The existing read-sheet and post-to-api step bodies are not rewritten or given new logic. + +# settings + +## page + +workflows/sheet-to-api-sync + +## workflow_yaml + +```yaml +name: sheet-to-api-sync +jobs: + read-sheet: + id: job-read-sheet-id + name: Read Spreadsheet + adaptor: "@openfn/language-googlesheets@4.1.1" + body: | + getValues(state.configuration.spreadsheetId, 'Submissions!A2:E'); + fn(state => { + const rows = state.data?.values || []; + return { ...state, rows }; + }); + post-to-api: + id: job-post-api-id + name: Post Rows to API + adaptor: "@openfn/language-http@7.3.1" + body: | + each( + $.rows, + post('https://intake.example.org/rows', state => ({ + body: { + ref: state.data[0], + name: state.data[1], + value: state.data[2] + } + })) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "0 7 * * *" + enabled: true +edges: + cron->read-sheet: + id: edge-cron-read + source_trigger: cron + target_job: read-sheet + condition_type: always + enabled: true + read-sheet->post-to-api: + id: edge-read-post + source_job: read-sheet + target_job: post-to-api + condition_type: on_job_success + enabled: true +``` + +## meta.session_id + +sess-multi-step-add-vague-step-0004 + +# turn + +## role + +user + +## content + +Can you add a step on the end to back up the data somewhere? I don't want to lose it if the API call goes wrong. diff --git a/services/global_chat/tests/acceptance/multi_step_changes/test_change_current_step_using_other_steps.md b/services/global_chat/tests/acceptance/multi_step_changes/test_change_current_step_using_other_steps.md new file mode 100644 index 00000000..c382caa8 --- /dev/null +++ b/services/global_chat/tests/acceptance/multi_step_changes/test_change_current_step_using_other_steps.md @@ -0,0 +1,140 @@ +--- +id: global-chat.multi-step.change-current-step-using-other-steps +service: global_chat +judges: [general, openfn_code_quality] +--- + +# notes + +The user is on the DHIS2 upload step (the last step) and asks to make it report +its record count "the way the other steps do". The earlier steps share a +convention: each ends with a console.log reporting how many records it handled. +To satisfy the request the assistant has to read at least one other step to see +what that convention looks like, then apply an equivalent count-log to the +current step only. This is a good fit for the planner: it should inspect another +step's body and then edit the upload step. A blind single-step route cannot know +what "the way the other steps do" means without reading them. + +# quality_criteria + +- The upload-to-dhis2 step is updated to log how many records/events it processed (a console.log reporting a count), consistent with the count-logging the other steps already do. +- The change is applied only to the upload-to-dhis2 body — fetch-submissions and clean-records are left unchanged. + +# settings + +## page + +workflows/kobo-nutrition-to-dhis2/upload-to-dhis2 + +## workflow_yaml + +```yaml +name: kobo-nutrition-to-dhis2 +jobs: + fetch-submissions: + id: job-fetch-kobo-id + name: Fetch Submissions from Kobo + adaptor: "@openfn/language-kobotoolbox@4.3.1" + body: | + getSubmissions(state.configuration.formId, { + query: { submittedAfter: $.lastRunAt } + }); + fn(state => { + const submissions = state.data?.results || []; + console.log(`Fetched ${submissions.length} submissions from Kobo`); + return { ...state, submissions }; + }); + clean-records: + id: job-clean-id + name: Clean Records + adaptor: "@openfn/language-common@3.3.3" + body: | + fn(state => { + const records = state.submissions + .filter(s => s.child_name && s.muac && s.org_unit) + .map(s => ({ + name: s.child_name, + muac: Number(s.muac), + orgUnit: s.org_unit + })); + console.log(`Cleaned ${records.length} records, dropped ${state.submissions.length - records.length}`); + return { ...state, records }; + }); + upload-to-dhis2: + id: job-upload-dhis2-id + name: Upload to DHIS2 + adaptor: "@openfn/language-dhis2@8.1.1" + body: | + each( + $.records, + create('events', state => ({ + program: state.configuration.programId, + orgUnit: state.data.orgUnit, + occurredAt: new Date().toISOString(), + dataValues: [ + { dataElement: state.configuration.muacDataElement, value: state.data.muac } + ] + })) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "0 6 * * *" + enabled: true +edges: + cron->fetch-submissions: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-submissions + condition_type: always + enabled: true + fetch-submissions->clean-records: + id: edge-fetch-clean + source_job: fetch-submissions + target_job: clean-records + condition_type: on_job_success + enabled: true + clean-records->upload-to-dhis2: + id: edge-clean-upload + source_job: clean-records + target_job: upload-to-dhis2 + condition_type: on_job_success + enabled: true +``` + +## meta.session_id + +sess-multi-step-current-using-others-0001 + +# history + +## turn + +### role + +user + +### content + +[pg:workflows/kobo-nutrition-to-dhis2] This pulls nutrition submissions from Kobo, tidies them up and pushes them into DHIS2 as events. + +## turn + +### role + +assistant + +### content + +That's right — the workflow fetches submissions from Kobo each morning, cleans them into MUAC records, and creates DHIS2 events for each one. + +# turn + +## role + +user + +## content + +When I read the logs I can see how many records the earlier steps handled, but this one says nothing. Can you make it report its count the way the others do? diff --git a/services/global_chat/tests/acceptance/multi_step_changes/test_change_different_step_from_another_step.md b/services/global_chat/tests/acceptance/multi_step_changes/test_change_different_step_from_another_step.md new file mode 100644 index 00000000..f69dbeeb --- /dev/null +++ b/services/global_chat/tests/acceptance/multi_step_changes/test_change_different_step_from_another_step.md @@ -0,0 +1,110 @@ +--- +id: global-chat.multi-step.change-different-step-from-another-step +service: global_chat +judges: [general, openfn_code_quality] +--- + +# notes + +The user is parked on the send-summary step (the last step) but the change they +ask for is about the first step — they want the CommCare fetch to only pull +cases changed recently instead of everything. This is still a single-step edit, +just not the focused one, so the router should resolve the target to the +fetch-cases step (job_key = fetch-cases) and route directly to job_code_agent. +The model must not edit the step the page points at; it must edit the step the +request is actually about. + +# quality_criteria + +- The fetch-cases step is changed so it no longer pulls every case each run — e.g. a date/modified filter on the query (roughly the last week) OR incremental sync (a cursor / since-last-run filter). Either approach satisfies the intent. +- The change lands on the fetch step's logic. The send-summary step (the page the user is currently on) is NOT modified just because it is the focused step. + +# settings + +## page + +workflows/commcare-case-sync/send-summary + +## workflow_yaml + +```yaml +name: commcare-case-sync +jobs: + fetch-cases: + id: job-fetch-cases-id + name: Fetch Cases from CommCare + adaptor: "@openfn/language-commcare@4.1.1" + body: | + get('/api/v0.5/case', { + query: { type: 'patient', limit: 100 } + }); + fn(state => { + const cases = state.data?.objects || []; + return { ...state, cases }; + }); + write-to-db: + id: job-write-db-id + name: Write Cases to Postgres + adaptor: "@openfn/language-postgresql@8.1.1" + body: | + each( + $.cases, + upsert('patients', 'case_id', state => ({ + case_id: state.data.case_id, + full_name: state.data.properties?.full_name, + dob: state.data.properties?.dob, + updated_at: state.data.server_date_modified + })) + ); + send-summary: + id: job-send-summary-id + name: Send Run Summary + adaptor: "@openfn/language-http@7.3.1" + body: | + post('https://hooks.example.org/notify', state => ({ + body: { + workflow: 'commcare-case-sync', + synced: state.cases.length, + finishedAt: new Date().toISOString() + } + })); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "0 */4 * * *" + enabled: true +edges: + cron->fetch-cases: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-cases + condition_type: always + enabled: true + fetch-cases->write-to-db: + id: edge-fetch-write + source_job: fetch-cases + target_job: write-to-db + condition_type: on_job_success + enabled: true + write-to-db->send-summary: + id: edge-write-summary + source_job: write-to-db + target_job: send-summary + condition_type: on_job_success + enabled: true +``` + +## meta.session_id + +sess-multi-step-change-different-step-0002 + +# turn + +## role + +user + +## content + +This runs every four hours but it's grabbing every case each time, which is wasteful. The first step should only pull cases that have changed in the last week or so. diff --git a/services/global_chat/tests/acceptance/multi_step_changes/test_several_changes_across_steps.md b/services/global_chat/tests/acceptance/multi_step_changes/test_several_changes_across_steps.md new file mode 100644 index 00000000..ae35897f --- /dev/null +++ b/services/global_chat/tests/acceptance/multi_step_changes/test_several_changes_across_steps.md @@ -0,0 +1,107 @@ +--- +id: global-chat.multi-step.several-changes-across-steps +service: global_chat +judges: [general, openfn_code_quality] +--- + +# notes + +From the workflow overview the user asks for two unrelated code changes that +land on two different steps in one message: the fetch step should also bring +back phone numbers, and the final Salesforce upsert should skip contacts with no +email. This needs the planner, which should call the job code agent at least +twice (once per affected step). Each change must land on the right step and the +untouched transform step must be preserved. + +# quality_criteria + +- Phone numbers are picked up at the fetch step and carried through so they reach Salesforce (it is fine, and expected, for transform-contacts to also be updated to map the phone field through). +- Contacts without an email address are skipped rather than upserted to Salesforce (the skip may be implemented at the transform or the upsert step). +- Both requested changes are made; neither is silently dropped. + +# settings + +## page + +workflows/crm-to-salesforce-sync + +## workflow_yaml + +```yaml +name: crm-to-salesforce-sync +jobs: + fetch-contacts: + id: job-fetch-contacts-id + name: Fetch Contacts from CRM + adaptor: "@openfn/language-http@7.3.1" + body: | + get('https://crm.example.org/api/contacts', { + query: { fields: 'id,first_name,last_name,email', updated_since: $.lastRunAt } + }); + fn(state => { + const contacts = state.data?.contacts || []; + return { ...state, contacts }; + }); + transform-contacts: + id: job-transform-id + name: Transform Contacts + adaptor: "@openfn/language-common@3.3.3" + body: | + fn(state => { + const records = state.contacts.map(c => ({ + ExternalId__c: c.id, + FirstName: c.first_name, + LastName: c.last_name, + Email: c.email + })); + return { ...state, records }; + }); + upsert-to-salesforce: + id: job-upsert-sf-id + name: Upsert Contacts to Salesforce + adaptor: "@openfn/language-salesforce@9.1.1" + body: | + each( + $.records, + upsert('Contact', 'ExternalId__c', state => state.data) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "30 2 * * *" + enabled: true +edges: + cron->fetch-contacts: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-contacts + condition_type: always + enabled: true + fetch-contacts->transform-contacts: + id: edge-fetch-transform + source_job: fetch-contacts + target_job: transform-contacts + condition_type: on_job_success + enabled: true + transform-contacts->upsert-to-salesforce: + id: edge-transform-upsert + source_job: transform-contacts + target_job: upsert-to-salesforce + condition_type: on_job_success + enabled: true +``` + +## meta.session_id + +sess-multi-step-several-changes-0003 + +# turn + +## role + +user + +## content + +Couple of things I want to fix here: the contacts we pull in should include their phone numbers too, and at the end please don't push anyone across to Salesforce if they don't have an email on file. From 66b6ee2ef2c8f794e3471924c00b419e6465f38e Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Wed, 24 Jun 2026 18:01:52 +0100 Subject: [PATCH 14/16] add changeset --- .changeset/early-carrots-look.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/early-carrots-look.md diff --git a/.changeset/early-carrots-look.md b/.changeset/early-carrots-look.md new file mode 100644 index 00000000..8d6e0766 --- /dev/null +++ b/.changeset/early-carrots-look.md @@ -0,0 +1,5 @@ +--- +"apollo": major +--- + +enable global assistant multi step changes From cc8a9b270a39ebc86e1c763d138722bfa3cb6c90 Mon Sep 17 00:00:00 2001 From: "Hanna Paasivirta (OpenFn)" Date: Wed, 24 Jun 2026 18:13:11 +0100 Subject: [PATCH 15/16] add prefix unit test --- .../job_chat/tests/unit/test_page_prefix.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 services/job_chat/tests/unit/test_page_prefix.py diff --git a/services/job_chat/tests/unit/test_page_prefix.py b/services/job_chat/tests/unit/test_page_prefix.py new file mode 100644 index 00000000..98e0ec7f --- /dev/null +++ b/services/job_chat/tests/unit/test_page_prefix.py @@ -0,0 +1,42 @@ +"""Unit tests for page-navigation prefix handling. + +`extract_page_prefix_from_last_turn` reads the `[pg:...]` tag off the previous +user turn so job_chat can detect navigation between turns. It pairs with +`add_page_prefix`, which writes that tag when building history. These lock in the +round-trip and the guard against non-string history content (e.g. a stale +planner-format turn whose content is a content-block list). +""" + +from job_chat.job_chat import extract_page_prefix_from_last_turn +from util import add_page_prefix + +PAGE = {"type": "job_code", "name": "Transform", "adaptor": "http@6.5.4"} + + +def _turns(user_content): + return [{"role": "user", "content": user_content}, {"role": "assistant", "content": "ok"}] + + +def test_extracts_prefix_from_previous_user_turn(): + history = _turns("[pg:job_code/Transform/http@6.5.4] tweak this") + assert extract_page_prefix_from_last_turn(history) == "[pg:job_code/Transform/http@6.5.4]" + + +def test_returns_none_when_fewer_than_two_turns(): + assert extract_page_prefix_from_last_turn([]) is None + assert extract_page_prefix_from_last_turn([{"role": "user", "content": "[pg:x] hi"}]) is None + + +def test_returns_none_when_no_prefix(): + assert extract_page_prefix_from_last_turn(_turns("just a message")) is None + + +def test_returns_none_when_content_not_string(): + history = _turns([{"type": "tool_result", "content": "x"}]) + assert extract_page_prefix_from_last_turn(history) is None + + +def test_roundtrip_with_add_page_prefix(): + history = _turns(add_page_prefix("tweak this", PAGE)) + expected = add_page_prefix("", PAGE).strip() + assert extract_page_prefix_from_last_turn(history) == expected From 7b72dcc04434753aefc9aa245cf08eb3730ba7f1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:37:06 +0000 Subject: [PATCH 16/16] Bump the python-minor-patch group across 1 directory with 9 updates Bumps the python-minor-patch group with 9 updates in the / directory: | Package | From | To | | --- | --- | --- | | [openai](https://github.com/openai/openai-python) | `2.41.1` | `2.44.0` | | [anthropic](https://github.com/anthropics/anthropic-sdk-python) | `0.109.1` | `0.112.0` | | [langchain-core](https://github.com/langchain-ai/langchain) | `1.4.6` | `1.4.8` | | [langchain-openai](https://github.com/langchain-ai/langchain) | `1.3.0` | `1.3.3` | | [pytest](https://github.com/pytest-dev/pytest) | `9.0.3` | `9.1.1` | | [sentry-sdk](https://github.com/getsentry/sentry-python) | `2.62.0` | `2.63.0` | | [langfuse](https://github.com/langfuse/langfuse) | `4.7.1` | `4.12.0` | | [opentelemetry-instrumentation-threading](https://github.com/open-telemetry/opentelemetry-python-contrib) | `0.63b1` | `0.64b0` | | [ruff](https://github.com/astral-sh/ruff) | `0.15.17` | `0.15.20` | Updates `openai` from 2.41.1 to 2.44.0 - [Release notes](https://github.com/openai/openai-python/releases) - [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-python/compare/v2.41.1...v2.44.0) Updates `anthropic` from 0.109.1 to 0.112.0 - [Release notes](https://github.com/anthropics/anthropic-sdk-python/releases) - [Changelog](https://github.com/anthropics/anthropic-sdk-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/anthropics/anthropic-sdk-python/compare/v0.109.1...v0.112.0) Updates `langchain-core` from 1.4.6 to 1.4.8 - [Release notes](https://github.com/langchain-ai/langchain/releases) - [Commits](https://github.com/langchain-ai/langchain/compare/langchain-core==1.4.6...langchain-core==1.4.8) Updates `langchain-openai` from 1.3.0 to 1.3.3 - [Release notes](https://github.com/langchain-ai/langchain/releases) - [Commits](https://github.com/langchain-ai/langchain/compare/langchain-openai==1.3.0...langchain-openai==1.3.3) Updates `pytest` from 9.0.3 to 9.1.1 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.3...9.1.1) Updates `sentry-sdk` from 2.62.0 to 2.63.0 - [Release notes](https://github.com/getsentry/sentry-python/releases) - [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-python/compare/2.62.0...2.63.0) Updates `langfuse` from 4.7.1 to 4.12.0 - [Release notes](https://github.com/langfuse/langfuse/releases) - [Commits](https://github.com/langfuse/langfuse/commits) Updates `opentelemetry-instrumentation-threading` from 0.63b1 to 0.64b0 - [Release notes](https://github.com/open-telemetry/opentelemetry-python-contrib/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-python-contrib/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-python-contrib/commits) Updates `ruff` from 0.15.17 to 0.15.20 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.17...0.15.20) --- updated-dependencies: - dependency-name: openai dependency-version: 2.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: anthropic dependency-version: 0.112.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: langchain-core dependency-version: 1.4.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: langchain-openai dependency-version: 1.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: pytest dependency-version: 9.1.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: sentry-sdk dependency-version: 2.63.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: langfuse dependency-version: 4.12.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: opentelemetry-instrumentation-threading dependency-version: 0.64b0 dependency-type: direct:production dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.15.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 163 +++++++++++++++++++++++++------------------------ pyproject.toml | 16 ++--- 2 files changed, 90 insertions(+), 89 deletions(-) diff --git a/poetry.lock b/poetry.lock index 78aab2e3..39968c2c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -199,14 +199,14 @@ files = [ [[package]] name = "anthropic" -version = "0.109.1" +version = "0.112.0" description = "The official Python library for the anthropic API" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "anthropic-0.109.1-py3-none-any.whl", hash = "sha256:ce7d94a7657f2aa29338cca448945eac621b4f62c1794cf461cb32847223e9b8"}, - {file = "anthropic-0.109.1.tar.gz", hash = "sha256:83e06b3d9d40ff5898f588020e0cc4e42187de954549a3b5fbe6e2685a09c785"}, + {file = "anthropic-0.112.0-py3-none-any.whl", hash = "sha256:bcc6268612c716dbb77133dd60fc41d26016d1b81dee9a52314d210193638751"}, + {file = "anthropic-0.112.0.tar.gz", hash = "sha256:e180cd91aa5b9b32e4007fe69892ab128d8a86b9f90825103b1903fbc977d0af"}, ] [package.dependencies] @@ -1033,19 +1033,19 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" [[package]] name = "langchain-core" -version = "1.4.6" +version = "1.4.8" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_core-1.4.6-py3-none-any.whl", hash = "sha256:84b73716aa9f8b529b426ea256bb71bcb55dea5980212e54c89c9a040fd50230"}, - {file = "langchain_core-1.4.6.tar.gz", hash = "sha256:fb8547f83587c8f646f2136b106b732a974ffbff5537799125d16ed4c326eb63"}, + {file = "langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa"}, + {file = "langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a"}, ] [package.dependencies] jsonpatch = ">=1.33.0,<2.0.0" -langchain-protocol = ">=0.0.14" +langchain-protocol = ">=0.0.17" langsmith = ">=0.3.45,<1.0.0" packaging = ">=23.2.0" pydantic = ">=2.7.4,<3.0.0" @@ -1056,18 +1056,18 @@ uuid-utils = ">=0.12.0,<1.0" [[package]] name = "langchain-openai" -version = "1.3.0" +version = "1.3.3" description = "An integration package connecting OpenAI and LangChain" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_openai-1.3.0-py3-none-any.whl", hash = "sha256:1d32326aea9b780c65698568ad253a82c4318b434112d05fb578aa20102c26e6"}, - {file = "langchain_openai-1.3.0.tar.gz", hash = "sha256:a6aa48dc5a00249eb75a7bc15d3cb342ba2494e9c397faec0586e73cf090ecf2"}, + {file = "langchain_openai-1.3.3-py3-none-any.whl", hash = "sha256:e469659862c8aabba4f6653df973206e7be54f98cf2275c86be7f06b7abe20d7"}, + {file = "langchain_openai-1.3.3.tar.gz", hash = "sha256:143769bf943820b80db769e47ca8fd0aac08ed18714519333b044c4431e9aa67"}, ] [package.dependencies] -langchain-core = ">=1.4.3,<2.0.0" +langchain-core = ">=1.4.7,<2.0.0" openai = ">=2.26.0,<3.0.0" tiktoken = ">=0.7.0,<1.0.0" @@ -1094,14 +1094,14 @@ simsimd = ">=5.9.11" [[package]] name = "langchain-protocol" -version = "0.0.16" +version = "0.0.18" description = "Python bindings for the LangChain agent streaming protocol" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_protocol-0.0.16-py3-none-any.whl", hash = "sha256:3658c142c5d0fb3a023a4be442ce4c15c6d626aab6135eb79a76dc64ad19c3c3"}, - {file = "langchain_protocol-0.0.16.tar.gz", hash = "sha256:806c7cdd951b1c4f692fa40fce60821ff0f221d4360e27673ddf2c2b99c2b7ff"}, + {file = "langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a"}, + {file = "langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6"}, ] [package.dependencies] @@ -1124,14 +1124,14 @@ langchain-core = ">=1.2.31,<2.0.0" [[package]] name = "langfuse" -version = "4.7.1" +version = "4.12.0" description = "A client library for accessing langfuse" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "langfuse-4.7.1-py3-none-any.whl", hash = "sha256:a4e59c81ad5e5b16a65d3849f4923ebc3ad6e67ec803ada83d50c0cb66149490"}, - {file = "langfuse-4.7.1.tar.gz", hash = "sha256:f9e262eceedb353b191c1da1f8452d1e8ebf52297ca20e160cda0206608e3a40"}, + {file = "langfuse-4.12.0-py3-none-any.whl", hash = "sha256:55f6231d1e64dc6e9debe776cb3f06eb93ca7afb51322ce54571fa7493ac05c9"}, + {file = "langfuse-4.12.0.tar.gz", hash = "sha256:af4c12ae61f69726e0ac73d14a4ada1a119013d5558598828d382f2a238d7314"}, ] [package.dependencies] @@ -1142,7 +1142,7 @@ opentelemetry-exporter-otlp-proto-http = ">=1.33.1,<2" opentelemetry-sdk = ">=1.33.1,<2" packaging = ">=23.2,<27.0" pydantic = ">=2,<3" -wrapt = ">=1.14,<2" +wrapt = ">=1.14,<3" [[package]] name = "langsmith" @@ -1408,14 +1408,14 @@ files = [ [[package]] name = "openai" -version = "2.41.1" +version = "2.44.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "openai-2.41.1-py3-none-any.whl", hash = "sha256:a939565f350cb7443cb843b801b88c716ac8024b492fb94ca269d5f6b1bbefd6"}, - {file = "openai-2.41.1.tar.gz", hash = "sha256:23d617a0432457ad844973bee8f540be9da90894f7c5686852d2d365da058f57"}, + {file = "openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad"}, + {file = "openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d"}, ] [package.dependencies] @@ -1430,20 +1430,21 @@ typing-extensions = ">=4.14,<5" [package.extras] aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"] +bedrock = ["botocore (>=1.40.0,<1.43) ; python_version < \"3.10\"", "botocore (>=1.40.0,<2) ; python_version >= \"3.10\""] datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] realtime = ["websockets (>=13,<16)"] voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] [[package]] name = "opentelemetry-api" -version = "1.42.1" +version = "1.43.0" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714"}, - {file = "opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716"}, + {file = "opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd"}, + {file = "opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1"}, ] [package.dependencies] @@ -1451,37 +1452,37 @@ typing-extensions = ">=4.5.0" [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.42.1" +version = "1.43.0" description = "OpenTelemetry Protobuf encoding" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.43.0-py3-none-any.whl", hash = "sha256:123c3f9cc87218562490c63b36f497bf3a722faf174a515d1443f31ababa6264"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f"}, ] [package.dependencies] -opentelemetry-proto = "1.42.1" +opentelemetry-proto = "1.43.0" [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.42.1" +version = "1.43.0" description = "OpenTelemetry Collector Protobuf over HTTP Exporter" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.43.0-py3-none-any.whl", hash = "sha256:647f603aa8efdbdb4dbff842e0729d0406a6fff26b295a72d3d60e7d963b2610"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.43.0.tar.gz", hash = "sha256:fa8a42bb7d00ee5391f4c0b04d8e6a46c03caa437903296ab73a81dc11ba118f"}, ] [package.dependencies] googleapis-common-protos = ">=1.52,<2.0" opentelemetry-api = ">=1.15,<2.0" -opentelemetry-exporter-otlp-proto-common = "1.42.1" -opentelemetry-proto = "1.42.1" -opentelemetry-sdk = ">=1.42.1,<1.43.0" +opentelemetry-exporter-otlp-proto-common = "1.43.0" +opentelemetry-proto = "1.43.0" +opentelemetry-sdk = ">=1.43.0,<1.44.0" requests = ">=2.7,<3.0" typing-extensions = ">=4.5.0" @@ -1490,19 +1491,19 @@ gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] [[package]] name = "opentelemetry-instrumentation" -version = "0.63b1" +version = "0.64b0" description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c"}, - {file = "opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541"}, + {file = "opentelemetry_instrumentation-0.64b0-py3-none-any.whl", hash = "sha256:133ab7ffca796557aec059bf6be3190a34b6dea987f25be3d9409e230cbdad8b"}, + {file = "opentelemetry_instrumentation-0.64b0.tar.gz", hash = "sha256:b47d528dead6271d7743114417eb67fc915bd9258111c48dbf9a4951d2efa88d"}, ] [package.dependencies] opentelemetry-api = ">=1.4,<2.0" -opentelemetry-semantic-conventions = "0.63b1" +opentelemetry-semantic-conventions = "0.64b0" packaging = ">=18.0" wrapt = ">=1.0.0,<3.0.0" @@ -1529,51 +1530,51 @@ instruments = ["anthropic"] [[package]] name = "opentelemetry-instrumentation-threading" -version = "0.63b1" +version = "0.64b0" description = "Thread context propagation support for OpenTelemetry" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_instrumentation_threading-0.63b1-py3-none-any.whl", hash = "sha256:33059298e68c94b13c38b562ad28799ec16a2fd06182ebfc762bb4e956e55d94"}, - {file = "opentelemetry_instrumentation_threading-0.63b1.tar.gz", hash = "sha256:afa8c2cada8ed136f07b04dc8739bc861a15e9a5edea1a65e4c5e1919c62946c"}, + {file = "opentelemetry_instrumentation_threading-0.64b0-py3-none-any.whl", hash = "sha256:a285ffa750a958f7d368e947f5679a0214d588242cbffba0f5934cb02e9a17f4"}, + {file = "opentelemetry_instrumentation_threading-0.64b0.tar.gz", hash = "sha256:0a07d7329f69dfae5036a7cb184f502c8b91cb0538012f5304bd32ffe9ade451"}, ] [package.dependencies] opentelemetry-api = ">=1.12,<2.0" -opentelemetry-instrumentation = "0.63b1" +opentelemetry-instrumentation = "0.64b0" wrapt = ">=1.0.0,<3.0.0" [[package]] name = "opentelemetry-proto" -version = "1.42.1" +version = "1.43.0" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c"}, - {file = "opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6"}, + {file = "opentelemetry_proto-1.43.0-py3-none-any.whl", hash = "sha256:c58f1f7ef84bc7dc2834016c0c37fe0081dde7ca9f6339be1970fbf9cdaaa90d"}, + {file = "opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924"}, ] [package.dependencies] -protobuf = ">=5.0,<7.0" +protobuf = ">=5.0,<8.0" [[package]] name = "opentelemetry-sdk" -version = "1.42.1" +version = "1.43.0" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d"}, - {file = "opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7"}, + {file = "opentelemetry_sdk-1.43.0-py3-none-any.whl", hash = "sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823"}, + {file = "opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9"}, ] [package.dependencies] -opentelemetry-api = "1.42.1" -opentelemetry-semantic-conventions = "0.63b1" +opentelemetry-api = "1.43.0" +opentelemetry-semantic-conventions = "0.64b0" typing-extensions = ">=4.5.0" [package.extras] @@ -1581,18 +1582,18 @@ file-configuration = ["jsonschema (>=4.0)", "pyyaml (>=6.0)"] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.63b1" +version = "0.64b0" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682"}, - {file = "opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9"}, + {file = "opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6"}, + {file = "opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c"}, ] [package.dependencies] -opentelemetry-api = "1.42.1" +opentelemetry-api = "1.43.0" typing-extensions = ">=4.5.0" [[package]] @@ -2203,14 +2204,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, - {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, ] [package.dependencies] @@ -2499,42 +2500,42 @@ requests = ">=2.0.1,<3.0.0" [[package]] name = "ruff" -version = "0.15.17" +version = "0.15.20" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f"}, - {file = "ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7"}, - {file = "ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d"}, - {file = "ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed"}, - {file = "ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e"}, - {file = "ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d"}, - {file = "ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c"}, - {file = "ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978"}, - {file = "ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719"}, - {file = "ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4"}, - {file = "ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7"}, - {file = "ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f"}, - {file = "ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f"}, - {file = "ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8"}, - {file = "ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392"}, - {file = "ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084"}, - {file = "ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456"}, - {file = "ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219"}, + {file = "ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078"}, + {file = "ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b"}, + {file = "ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460"}, + {file = "ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21"}, + {file = "ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415"}, + {file = "ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca"}, + {file = "ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566"}, ] [[package]] name = "sentry-sdk" -version = "2.62.0" +version = "2.63.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "sentry_sdk-2.62.0-py3-none-any.whl", hash = "sha256:27f61d13a86c3c1648dec666dd5a64f79772dd6a84b446f11866601ecab24f6f"}, - {file = "sentry_sdk-2.62.0.tar.gz", hash = "sha256:3c870b9f50d9fd15b58c817dbde1c7cfaa9fe3f05df0a4c6edd5571cb82f5491"}, + {file = "sentry_sdk-2.63.0-py3-none-any.whl", hash = "sha256:3a9b5ddd403f79eb73bd670f75f04485819db53d28f76ced7bc09041cb0dfd6a"}, + {file = "sentry_sdk-2.63.0.tar.gz", hash = "sha256:2a1502bf864769275dbc8c2c9fc7a0f7f5e18358180b615d262d13a31ffba216"}, ] [package.dependencies] @@ -3538,4 +3539,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "3.11.*" -content-hash = "2646f548f3e3a9ae76d4d2390063a3a96227ab6fea144444b446f58946953f24" +content-hash = "2ba91758f0bc1cfcdd6758fd5dfb9f111303eedc6a30ff0dccab2e0c050de29d" diff --git a/pyproject.toml b/pyproject.toml index 4a1210d6..aba9ab96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,9 @@ requires-poetry = ">=2.3.2" [tool.poetry.dependencies] python = "3.11.*" -openai = "^2.41" +openai = "^2.44" python-dotenv = "^1.2.2" -anthropic = "^0.109.1" +anthropic = "^0.112.0" langchain-pinecone = "^0.2.13" langchain-core = "^1.4" @@ -20,19 +20,19 @@ langchain-community = "^0.4.2" langchain-openai = "^1.3" langchain-text-splitters = "^1.1" nltk = "^3.9.3" -pytest = "^9.0.3" -sentry-sdk = "^2.62.0" +pytest = "^9.1.1" +sentry-sdk = "^2.63.0" psycopg2-binary = "^2.9.10" -langfuse = "^4.0.1" +langfuse = "^4.12.0" opentelemetry-instrumentation-anthropic = "^0.61.0" -opentelemetry-instrumentation-threading = "0.63b1" +opentelemetry-instrumentation-threading = "0.64b0" [tool.poetry.group.dev] optional = false [tool.poetry.group.dev.dependencies] -pytest = "^9.0.3" -ruff = "^0.15.17" +pytest = "^9.1.1" +ruff = "^0.15.20" [build-system] requires = ["poetry-core"]