From 7420dd66cb0b8f07b4fd90306c9390fb5846b8b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:07:26 +0000 Subject: [PATCH 01/26] Bump elysia from 1.4.27 to 1.4.29 Bumps [elysia](https://github.com/elysiajs/elysia) from 1.4.27 to 1.4.29. - [Release notes](https://github.com/elysiajs/elysia/releases) - [Changelog](https://github.com/elysiajs/elysia/blob/main/CHANGELOG.md) - [Commits](https://github.com/elysiajs/elysia/compare/1.4.27...1.4.29) --- updated-dependencies: - dependency-name: elysia dependency-version: 1.4.29 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7dc082eb..93f2ca02 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@elysiajs/html": "^1.4.0", "@openfn/adaptor-apis": "^0.3.0", "@sentry/bun": "^10.60.0", - "elysia": "1.4.27", + "elysia": "1.4.29", "jsdoc-babel": "^0.5.0" }, "nodemonConfig": { From 377dc26f78f599d4b5eb019cfe8126d4295f728c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:28:17 +0000 Subject: [PATCH 02/26] Bump urllib3 from 2.7.0 to 2.8.0 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.7.0 to 2.8.0. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.7.0...2.8.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.8.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- poetry.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6ea8d960..fbf407a4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1042,7 +1042,7 @@ pydantic-settings = ">=2.10.1,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" requests = ">=2.32.5,<3.0.0" sqlalchemy = ">=1.4.0,<3.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" [[package]] name = "langchain-core" @@ -1064,7 +1064,7 @@ langsmith = ">=0.3.45,<1.0.0" packaging = ">=23.2.0" pydantic = ">=2.7.4,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" typing-extensions = ">=4.7.0,<5.0.0" uuid-utils = ">=0.12.0,<1.0" @@ -1102,7 +1102,7 @@ files = [ httpx = ">=0.28.0" langchain-core = ">=0.3.34,<2.0.0" langchain-openai = ">=0.3.11" -numpy = ">=1.26.4,<2.0.2 || >2.0.2" +numpy = ">=1.26.4,!=2.0.2" pinecone = {version = ">=6.0.0,<8.0.0", extras = ["asyncio"]} pydantic = ">=2.11.1" simsimd = ">=5.9.11" @@ -3095,14 +3095,14 @@ files = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, - {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, + {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, + {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, ] [package.extras] From 7644450c6fae937a76333908c30be4da22a1ba12 Mon Sep 17 00:00:00 2001 From: doc-han Date: Fri, 2 Oct 2026 09:58:32 +0000 Subject: [PATCH 03/26] feat: forked python process --- platform/src/bridge.ts | 200 +++++++++++++++++++++++++++++++++++++++- services/entry.py | 69 ++++++++------ services/fork_server.py | 109 ++++++++++++++++++++++ 3 files changed, 347 insertions(+), 31 deletions(-) create mode 100644 services/fork_server.py diff --git a/platform/src/bridge.ts b/platform/src/bridge.ts index 240e9ade..edceebec 100644 --- a/platform/src/bridge.ts +++ b/platform/src/bridge.ts @@ -1,6 +1,7 @@ import readline from "node:readline"; +import net from "node:net"; import path from "node:path"; -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { chmod, rm } from "node:fs/promises"; import { getInternalToken } from "./auth/internal-token"; import { @@ -17,6 +18,199 @@ import pkg from "../../package.json"; // stream. Only these are forwarded to the caller. const LOG_LINE = /^(INFO|DEBUG|ERROR|WARNING):/; +const USE_FORK_SERVER = ["1", "true", "yes"].includes( + (process.env.APOLLO_FORK_SERVER ?? "").toLowerCase() +); + +interface ForkMaster { + proc: ChildProcess; + socketPath: string; +} +let forkMaster: Promise | null = null; +let forkMasterProc: ChildProcess | null = null; + +function startForkMaster(): Promise { + const socketPath = path.resolve(`tmp/fork_server-${process.pid}.sock`); + const proc = spawn( + "poetry", + ["run", "python", "services/fork_server.py", "--socket", socketPath], + { + env: { + ...process.env, + APOLLO_INTERNAL_TOKEN: getInternalToken(), + APOLLO_VERSION: pkg.version, + OBJC_DISABLE_INITIALIZE_FORK_SAFETY: "YES", + }, + } + ); + forkMasterProc = proc; + + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(subprocessSpawnFailed("fork_server", new Error("not READY in time"))), + 60_000 + ); + readline.createInterface({ input: proc.stdout!, crlfDelay: Infinity }).on("line", (line) => { + console.log(`fork-server: ${line}`); + if (line.startsWith("READY")) { + clearTimeout(timeout); + resolve({ proc, socketPath }); + } + }); + readline + .createInterface({ input: proc.stderr!, crlfDelay: Infinity }) + .on("line", (line) => console.error(`fork-server: ${line}`)); + + proc.on("error", (err) => { + clearTimeout(timeout); + reject(subprocessSpawnFailed("fork_server", err)); + }); + proc.on("exit", (code, sig) => { + clearTimeout(timeout); + console.error(`fork-server exited (code=${code} signal=${sig})`); + forkMaster = null; // next request starts a fresh master + forkMasterProc = null; + rm(socketPath).catch(() => {}); + }); + }); +} + +function getForkMaster(): Promise { + if (!forkMaster) { + forkMaster = startForkMaster().catch((err) => { + forkMaster = null; + throw err; + }); + } + return forkMaster; +} + +process.once("exit", () => forkMasterProc?.kill("SIGTERM")); + +function runForked( + scriptName: string, + port: number, + inputPath: string, + outputPath: string, + onLog?: (str: string) => void, + onEvent?: (type: string, payload: any) => void, + signal?: AbortSignal +): Promise { + return getForkMaster().then( + (master) => + new Promise((resolve, reject) => { + let childPid: number | null = null; + let exitCode: number | null = null; + let cancelled = false; + let hardKill: ReturnType | undefined; + + const cleanupFiles = async () => { + try { + await rm(inputPath); + await rm(outputPath); + } catch (e) { + console.error("Error removing temporary files"); + console.error(e); + } + }; + + const killChild = () => { + if (childPid == null) return; // killed once the PID: line arrives + try { + process.kill(childPid, "SIGTERM"); + } catch {} + hardKill = setTimeout(() => { + try { + process.kill(childPid!, "SIGKILL"); + } catch {} + }, 5_000); + hardKill.unref?.(); + }; + + const onAbort = () => { + cancelled = true; + console.warn(`cancelling ${scriptName}: client went away`); + killChild(); + conn.destroy(); + }; + + const conn = net.createConnection({ path: master.socketPath }); + conn.on("connect", () => { + conn.write( + JSON.stringify({ service: scriptName, input: inputPath, output: outputPath, port }) + "\n" + ); + }); + + // The child's stream: the service's own stdout, wrapped in PID:/EXIT:. + const rl = readline.createInterface({ input: conn, crlfDelay: Infinity }); + rl.on("line", (line) => { + if (line.startsWith("PID:")) { + childPid = Number(line.slice(4)) || null; + if (cancelled) killChild(); + } else if (line.startsWith("EXIT:")) { + exitCode = Number(line.slice(5)); + } else if (LOG_LINE.test(line)) { + console.log(line); + onLog?.(line); + } else if (/^(EVENT)\:/.test(line)) { + const [_prefix, type, ...payload] = line.split(":"); + let processedPayload: any = payload.join(":"); + try { + processedPayload = JSON.parse(processedPayload); + } catch (e) { + // No json, no problem + } + onEvent?.(type, processedPayload); + } + }); + + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + conn.on("error", async (err) => { + if (hardKill) clearTimeout(hardKill); + signal?.removeEventListener("abort", onAbort); + await cleanupFiles(); + reject(subprocessSpawnFailed(scriptName, err)); + }); + + conn.on("close", async () => { + rl.close(); + if (hardKill) clearTimeout(hardKill); + signal?.removeEventListener("abort", onAbort); + + const text = await Bun.file(outputPath) + .text() + .catch(() => ""); + await cleanupFiles(); + + if (cancelled) return reject(subprocessCancelled(scriptName, "SIGTERM")); + if (exitCode && exitCode !== 0) return reject(subprocessFailed(scriptName, exitCode)); + // No EXIT line => the child died mid-flight (crash/OOM/signal). + if (exitCode === null) return reject(subprocessKilled(scriptName, "UNKNOWN")); + if (text) { + try { + return resolve(JSON.parse(text)); + } catch (e) { + console.error(`Unreadable output from ${scriptName}`); + console.error(e); + return reject(malformedResult(scriptName)); + } + } + console.warn("No data returned from pythonland"); + return reject(emptyResult(scriptName)); + }); + }), + async (err) => { + await rm(inputPath).catch(() => {}); + await rm(outputPath).catch(() => {}); + throw err; + } + ); +} + /** Run a python script Each script will be run in its own thread because @@ -61,6 +255,10 @@ export const run = async ( throw subprocessSpawnFailed(scriptName, error); } + if (USE_FORK_SERVER) { + return runForked(scriptName, port, inputPath, outputPath, onLog, onEvent, signal); + } + return new Promise((resolve, reject) => { const proc = spawn( "poetry", diff --git a/services/entry.py b/services/entry.py index 238f476e..daf8f597 100644 --- a/services/entry.py +++ b/services/entry.py @@ -9,17 +9,15 @@ load_dotenv() -# Langfuse: init after load_dotenv so env vars are available, before any Anthropic client is created from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor from opentelemetry.instrumentation.threading import ThreadingInstrumentor -AnthropicInstrumentor().instrument() -ThreadingInstrumentor().instrument() - from langfuse import Langfuse from langfuse.span_filter import is_default_export_span from langfuse_util import mask_secrets +langfuse = None # set by bootstrap() + def _should_export_span(span): """Drop spans marked as tracing-disabled (user has not opted in).""" @@ -29,20 +27,6 @@ def _should_export_span(span): return is_default_export_span(span) -langfuse = Langfuse( - should_export_span=_should_export_span, - mask=mask_secrets, - release=os.getenv("APOLLO_VERSION", "unknown"), -) - -env = os.getenv('ENVIRONMENT', 'unknown') -trace_rates = { - 'development': 1, - 'staging': 0.05, - 'production': 0.03, - 'unknown': 0.0, - } - def _scrub_event(event: dict, _hint: dict) -> dict: """Mask keys in what Sentry is about to send. @@ -54,17 +38,41 @@ def _scrub_event(event: dict, _hint: dict) -> dict: return mask_secrets(event) -sentry_sdk.init( - dsn=os.getenv('SENTRY_DSN'), - environment=env, - sample_rate=1.0, - traces_sample_rate=trace_rates.get(env, 0.0), - enable_tracing=True, - auto_enabling_integrations=False, - before_send=_scrub_event, - # before_send covers error events only, and tracing is on. - before_send_transaction=_scrub_event, -) +def bootstrap() -> None: + global langfuse + + AnthropicInstrumentor().instrument() + ThreadingInstrumentor().instrument() + + langfuse = Langfuse( + should_export_span=_should_export_span, + mask=mask_secrets, + release=os.getenv("APOLLO_VERSION", "unknown"), + ) + + env = os.getenv("ENVIRONMENT", "unknown") + trace_rates = { + "development": 1, + "staging": 0.05, + "production": 0.03, + "unknown": 0.0, + } + + sentry_sdk.init( + dsn=os.getenv("SENTRY_DSN"), + environment=env, + sample_rate=1.0, + traces_sample_rate=trace_rates.get(env, 0.0), + enable_tracing=True, + auto_enabling_integrations=False, + before_send=_scrub_event, + # before_send covers error events only, and tracing is on. + before_send_transaction=_scrub_event, + ) + + +if os.environ.get("APOLLO_FORK_PRELOAD") != "1": + bootstrap() # At or above this the failure is ours; below it the caller sent something we # correctly refused. @@ -156,7 +164,8 @@ def call( code=500, message=str(e), type="INTERNAL_ERROR", ).to_dict() - langfuse.flush() + if langfuse is not None: + langfuse.flush() return _finish(result, output_path) diff --git a/services/fork_server.py b/services/fork_server.py new file mode 100644 index 00000000..9aecdce7 --- /dev/null +++ b/services/fork_server.py @@ -0,0 +1,109 @@ +import os +import sys + +# macOS aborts a forked child that touches an objc framework unless this is set +# before interpreter start, so re-exec once with it in the env. No-op on Linux. +# [To be removed] +if sys.platform == "darwin" and os.environ.get("OBJC_DISABLE_INITIALIZE_FORK_SAFETY") != "YES": + os.environ["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES" + os.execvpe(sys.executable, [sys.executable, *sys.argv], os.environ) + +# Tell entry.py to import its heavy modules but defer bootstrap() to each child. +os.environ["APOLLO_FORK_PRELOAD"] = "1" + +import argparse # noqa: E402 +import importlib # noqa: E402 +import json # noqa: E402 +import signal # noqa: E402 +import socket # noqa: E402 +import traceback # noqa: E402 + +import entry # noqa: E402 + + +def _preload_services() -> list[str]: + """Import an opt-in allowlist (APOLLO_FORK_PRELOAD_SERVICES) so children + inherit their deps. Not every service: some do I/O at import and would hang + the parent; entry.call() imports the rest on demand in the child.""" + names = [n.strip() for n in os.environ.get("APOLLO_FORK_PRELOAD_SERVICES", "").split(",") if n.strip()] + loaded = [] + for name in names: + try: + importlib.import_module(f"{name}.{name}") + loaded.append(name) + except Exception as e: + print(f"fork_server: could not preload {name}: {e}", file=sys.stderr, flush=True) + return loaded + + +def _run_child(conn: socket.socket, req: dict) -> None: + os.dup2(conn.fileno(), 1) + os.dup2(conn.fileno(), 2) + sys.stdout = os.fdopen(1, "w", buffering=1) + sys.stderr = os.fdopen(2, "w", buffering=1) + + sys.stdout.write(f"PID:{os.getpid()}\n") # first, so the bridge can cancel this child + sys.stdout.flush() + + code = 0 + try: + entry.bootstrap() + entry.call( + service=req["service"], + input_path=req.get("input"), + output_path=req.get("output"), + apollo_port=req.get("port"), + ) + except Exception: + traceback.print_exc() + code = 1 + finally: + sys.stdout.write(f"EXIT:{code}\n") + sys.stdout.flush() + os._exit(code) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--socket", required=True) + args = ap.parse_args() + + signal.signal(signal.SIGCHLD, signal.SIG_IGN) # auto-reap children + + loaded = _preload_services() + + if os.path.exists(args.socket): + os.unlink(args.socket) + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(args.socket) + srv.listen(128) + print(f"READY services={len(loaded)}", flush=True) # handshake the bridge waits for + + while True: + try: + conn, _ = srv.accept() + except InterruptedError: + continue + line = b"" + while not line.endswith(b"\n"): + chunk = conn.recv(65536) + if not chunk: + break + line += chunk + if not line: + conn.close() + continue + try: + req = json.loads(line) + except json.JSONDecodeError: + conn.close() + continue + + if os.fork() == 0: + srv.close() # the child must not hold the listening socket + _run_child(conn, req) # never returns + conn.close() # parent drops its copy; the child owns the reply + + +if __name__ == "__main__": + main() From 9b141bcdcfc4734f8fefaeecd218913cd5077e25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:36:53 +0000 Subject: [PATCH 04/26] Bump the python-minor-patch group with 7 updates Bumps the python-minor-patch group with 7 updates: | Package | From | To | | --- | --- | --- | | [langchain-core](https://github.com/langchain-ai/langchain) | `1.6.4` | `1.6.5` | | [langchain-openai](https://github.com/langchain-ai/langchain) | `1.6.3` | `1.6.6` | | [sentry-sdk](https://github.com/getsentry/sentry-python) | `2.70.0` | `2.71.0` | | [langfuse](https://github.com/langfuse/langfuse) | `4.15.4` | `4.15.6` | | [opentelemetry-instrumentation-anthropic](https://github.com/traceloop/openllmetry) | `0.62.3` | `0.62.4` | | [opentelemetry-instrumentation-threading](https://github.com/open-telemetry/opentelemetry-python-contrib) | `0.65b0` | `0.66b0` | | [ruff](https://github.com/astral-sh/ruff) | `0.16.8` | `0.16.9` | Updates `langchain-core` from 1.6.4 to 1.6.5 - [Release notes](https://github.com/langchain-ai/langchain/releases) - [Commits](https://github.com/langchain-ai/langchain/compare/langchain-core==1.6.4...langchain-core==1.6.5) Updates `langchain-openai` from 1.6.3 to 1.6.6 - [Release notes](https://github.com/langchain-ai/langchain/releases) - [Commits](https://github.com/langchain-ai/langchain/compare/langchain-openai==1.6.3...langchain-openai==1.6.6) Updates `sentry-sdk` from 2.70.0 to 2.71.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.70.0...2.71.0) Updates `langfuse` from 4.15.4 to 4.15.6 - [Release notes](https://github.com/langfuse/langfuse/releases) - [Commits](https://github.com/langfuse/langfuse/commits) Updates `opentelemetry-instrumentation-anthropic` from 0.62.3 to 0.62.4 - [Release notes](https://github.com/traceloop/openllmetry/releases) - [Changelog](https://github.com/traceloop/openllmetry/blob/main/CHANGELOG.md) - [Commits](https://github.com/traceloop/openllmetry/compare/0.62.3...0.62.4) Updates `opentelemetry-instrumentation-threading` from 0.65b0 to 0.66b0 - [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.16.8 to 0.16.9 - [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.16.8...0.16.9) --- updated-dependencies: - dependency-name: langchain-core dependency-version: 1.6.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: langchain-openai dependency-version: 1.6.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: sentry-sdk dependency-version: 2.71.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: langfuse dependency-version: 4.15.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: opentelemetry-instrumentation-anthropic dependency-version: 0.62.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: opentelemetry-instrumentation-threading dependency-version: 0.66b0 dependency-type: direct:production dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.16.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 188 ++++++++++++++++++++++++++++++------------------- pyproject.toml | 10 +-- 2 files changed, 119 insertions(+), 79 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6ea8d960..45944aed 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1042,18 +1042,18 @@ pydantic-settings = ">=2.10.1,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" requests = ">=2.32.5,<3.0.0" sqlalchemy = ">=1.4.0,<3.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" [[package]] name = "langchain-core" -version = "1.6.4" +version = "1.6.5" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_core-1.6.4-py3-none-any.whl", hash = "sha256:2f940c83f787676ad4dc651d49d053c7ec34bda5a6afcd224da2863ff53a0b84"}, - {file = "langchain_core-1.6.4.tar.gz", hash = "sha256:c1caae770d991bb6f2376bad791970bf77bb29abd1f15823fa9ea7dbac080fa0"}, + {file = "langchain_core-1.6.5-py3-none-any.whl", hash = "sha256:54c7b0e9314b9084fb04405bb33dc5d32986d512d92b7b8863899cd5f6267556"}, + {file = "langchain_core-1.6.5.tar.gz", hash = "sha256:bdb059fef65473fd444558dbb18f50901657402c43ceed9dc0fdc7b76a393c6b"}, ] [package.dependencies] @@ -1064,20 +1064,20 @@ langsmith = ">=0.3.45,<1.0.0" packaging = ">=23.2.0" pydantic = ">=2.7.4,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" typing-extensions = ">=4.7.0,<5.0.0" uuid-utils = ">=0.12.0,<1.0" [[package]] name = "langchain-openai" -version = "1.6.3" +version = "1.6.6" 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.6.3-py3-none-any.whl", hash = "sha256:c4529e694512e8058219998574b58aa8307d8cd3a13f4b1fc682dd31883f63ef"}, - {file = "langchain_openai-1.6.3.tar.gz", hash = "sha256:906953395f50a896159b7bac83a5de7ef627160056b69946b22b8cad8f39bbfc"}, + {file = "langchain_openai-1.6.6-py3-none-any.whl", hash = "sha256:60dcaa443ddfdf1559feaf1d25916f394c7dda809aebe22a769fc39f02705be7"}, + {file = "langchain_openai-1.6.6.tar.gz", hash = "sha256:7b3848f42825aca40165463aa37757051165eb292ba7f24658a2f58f9bb221f9"}, ] [package.dependencies] @@ -1102,7 +1102,7 @@ files = [ httpx = ">=0.28.0" langchain-core = ">=0.3.34,<2.0.0" langchain-openai = ">=0.3.11" -numpy = ">=1.26.4,<2.0.2 || >2.0.2" +numpy = ">=1.26.4,!=2.0.2" pinecone = {version = ">=6.0.0,<8.0.0", extras = ["asyncio"]} pydantic = ">=2.11.1" simsimd = ">=5.9.11" @@ -1139,14 +1139,14 @@ langchain-core = ">=1.2.31,<2.0.0" [[package]] name = "langfuse" -version = "4.15.4" +version = "4.15.6" description = "Langfuse Python SDK - LLM observability/tracing, datasets, experiments, LLM-as-a-judge evaluation, and prompt management" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "langfuse-4.15.4-py3-none-any.whl", hash = "sha256:8e75c70b1522a722a11ab6c7d88e27534a5d0c3844a1a464ac5eb548b014e9c3"}, - {file = "langfuse-4.15.4.tar.gz", hash = "sha256:b9a36194f4e968206d919313c24ea71c6ea68e21286370b01de752d20b26e7a3"}, + {file = "langfuse-4.15.6-py3-none-any.whl", hash = "sha256:2364d5f17342301a8651ef166abb5bb260aaadd94d1ac72aa30c298b3a395d85"}, + {file = "langfuse-4.15.6.tar.gz", hash = "sha256:af80ed1199e57c2641c9f228066bf9dc324657c06ce6e68660118fa46b237e35"}, ] [package.dependencies] @@ -1455,86 +1455,126 @@ voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] [[package]] name = "opentelemetry-api" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Python API" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef"}, - {file = "opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a"}, + {file = "opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3"}, + {file = "opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7"}, ] [package.dependencies] typing-extensions = ">=4.5.0" +[[package]] +name = "opentelemetry-exporter-http-transport" +version = "0.66b0" +description = "OpenTelemetry Exporters HTTP transport" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "opentelemetry_exporter_http_transport-0.66b0-py3-none-any.whl", hash = "sha256:c82689928a11505a2a0a26a04afe0ce06d270d3db984bc16c1a80ffa902db00e"}, + {file = "opentelemetry_exporter_http_transport-0.66b0.tar.gz", hash = "sha256:2c229b6593eaa22c86d9b8a15843dc23b406dbda00fb138339189aab07923b4e"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.15,<2.0" +urllib3 = {version = ">=1.26", optional = true, markers = "extra == \"urllib3\""} + +[package.extras] +requests = ["requests (>=2.25,<3.0)"] +urllib3 = ["urllib3 (>=1.26)"] + +[[package]] +name = "opentelemetry-exporter-otlp-common" +version = "0.66b0" +description = "OpenTelemetry OTLP HTTP export utilities" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "opentelemetry_exporter_otlp_common-0.66b0-py3-none-any.whl", hash = "sha256:35d24c867310f4713a9738b0202b6bade77955418fccb9e2f7eea01b76263916"}, + {file = "opentelemetry_exporter_otlp_common-0.66b0.tar.gz", hash = "sha256:362268ec6aa705e183776ff938539df1e8ce45bc5509d242538b1d40c26fe6a6"}, +] + +[package.dependencies] +opentelemetry-sdk = ">=1.45.0,<1.46.0" + +[package.extras] +http = ["opentelemetry-exporter-http-transport (==0.66b0)"] + [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Protobuf encoding" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694"}, - {file = "opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.45.0-py3-none-any.whl", hash = "sha256:7e1410ae6d3ed301f7a74bec96467d519d3f37e52f9d95b309ae125e5a1af863"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.45.0.tar.gz", hash = "sha256:36495115a0c6a7aa946cfda9d59b6ed4e917b6ab0f75cdaf66bc1b176ec1be1f"}, ] [package.dependencies] -opentelemetry-proto = "1.44.0" +opentelemetry-proto = "1.45.0" [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Collector Protobuf over HTTP Exporter" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.45.0-py3-none-any.whl", hash = "sha256:74385f99266dafdefff41f3da4b76f6dba79d237a9d4487e6ff1ee653c74a0a3"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.45.0.tar.gz", hash = "sha256:2f35496d96809f946f41b8805e6b93aec6c9b71b5fd759b75b6af4c084d992ae"}, ] [package.dependencies] googleapis-common-protos = ">=1.52,<2.0" opentelemetry-api = ">=1.15,<2.0" -opentelemetry-exporter-otlp-proto-common = "1.44.0" -opentelemetry-proto = "1.44.0" -opentelemetry-sdk = ">=1.44.0,<1.45.0" -requests = ">=2.7,<3.0" +opentelemetry-exporter-http-transport = {version = "0.66b0", extras = ["urllib3"]} +opentelemetry-exporter-otlp-common = "0.66b0" +opentelemetry-exporter-otlp-proto-common = "1.45.0" +opentelemetry-proto = "1.45.0" +opentelemetry-sdk = ">=1.45.0,<1.46.0" typing-extensions = ">=4.5.0" [package.extras] gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] +requests = ["opentelemetry-exporter-http-transport[requests] (==0.66b0)", "requests (>=2.7,<3.0)"] [[package]] name = "opentelemetry-instrumentation" -version = "0.65b0" +version = "0.66b0" description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137"}, - {file = "opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b"}, + {file = "opentelemetry_instrumentation-0.66b0-py3-none-any.whl", hash = "sha256:0b32e2fcfec1e4bd48511a3b77ce32bfb1c16dcd5479396b21f9086a4b06c0c9"}, + {file = "opentelemetry_instrumentation-0.66b0.tar.gz", hash = "sha256:316715ed7b33a12530c0ae3e8d4cdf26d70e1aad358d7ba256e5dea2d4ceed71"}, ] [package.dependencies] opentelemetry-api = ">=1.4,<2.0" -opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-semantic-conventions = "0.66b0" packaging = ">=18.0" wrapt = ">=1.0.0,<3.0.0" [[package]] name = "opentelemetry-instrumentation-anthropic" -version = "0.62.3" +version = "0.62.4" description = "OpenTelemetry Anthropic instrumentation" optional = false python-versions = "<4,>=3.10" groups = ["main"] files = [ - {file = "opentelemetry_instrumentation_anthropic-0.62.3-py3-none-any.whl", hash = "sha256:c6613d44a31b528716c99c5e94213d83401d7b31a2865bf395d992521b49623c"}, - {file = "opentelemetry_instrumentation_anthropic-0.62.3.tar.gz", hash = "sha256:54246ab1208aa33e1dd79d4aff1621e73ebc14cd98762798af029e1fc53c0266"}, + {file = "opentelemetry_instrumentation_anthropic-0.62.4-py3-none-any.whl", hash = "sha256:90fc6cc948f3f1b4bff762859ac54b82b5f99e9444538dee3690d73803508db4"}, + {file = "opentelemetry_instrumentation_anthropic-0.62.4.tar.gz", hash = "sha256:861d893c0e20022fa5bee6134cc36c1b5bf847cf831ec0d8b78b0a8267e2a21b"}, ] [package.dependencies] @@ -1548,31 +1588,31 @@ instruments = ["anthropic"] [[package]] name = "opentelemetry-instrumentation-threading" -version = "0.65b0" +version = "0.66b0" description = "Thread context propagation support for OpenTelemetry" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_instrumentation_threading-0.65b0-py3-none-any.whl", hash = "sha256:d8a1a1f35418a32769d469ef2d7e8401935e097a8553abcfba833da9c74736ce"}, - {file = "opentelemetry_instrumentation_threading-0.65b0.tar.gz", hash = "sha256:aefd23eb16c5e7a7c6c6eacdcb6c6f269ed8ed3a1b458bf5dd555b878bf58937"}, + {file = "opentelemetry_instrumentation_threading-0.66b0-py3-none-any.whl", hash = "sha256:4aaf38b96b47a04319b8d96bee4f1daa04e0d6c697525595746aeb61618c002d"}, + {file = "opentelemetry_instrumentation_threading-0.66b0.tar.gz", hash = "sha256:4c1c9adf42b83b6f44ca4bb99799e066ccfb1869c2029bc73d862c40a8ef932e"}, ] [package.dependencies] opentelemetry-api = ">=1.12,<2.0" -opentelemetry-instrumentation = "0.65b0" +opentelemetry-instrumentation = "0.66b0" wrapt = ">=1.0.0,<3.0.0" [[package]] name = "opentelemetry-proto" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Python Proto" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56"}, - {file = "opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3"}, + {file = "opentelemetry_proto-1.45.0-py3-none-any.whl", hash = "sha256:9731566359d7b8e1ee1e149d8e4ad6865bab965e657ad3387ec2784d16927666"}, + {file = "opentelemetry_proto-1.45.0.tar.gz", hash = "sha256:96ee414f24bc3f61ea8e17dc56b4348d4049d73db3eb17c6b3edf75b5b403300"}, ] [package.dependencies] @@ -1580,38 +1620,38 @@ protobuf = ">=5.0,<8.0" [[package]] name = "opentelemetry-sdk" -version = "1.44.0" +version = "1.45.0" description = "OpenTelemetry Python SDK" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad"}, - {file = "opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b"}, + {file = "opentelemetry_sdk-1.45.0-py3-none-any.whl", hash = "sha256:5dc634c946546f61b757c5b1781f9fd1a10e96ffaf7357c797e508fe9c57e75e"}, + {file = "opentelemetry_sdk-1.45.0.tar.gz", hash = "sha256:20caa5130505e386c67c3da1c76e446c842698ced54c76c6148679539aa97972"}, ] [package.dependencies] -opentelemetry-api = "1.44.0" -opentelemetry-semantic-conventions = "0.65b0" +opentelemetry-api = "1.45.0" +opentelemetry-semantic-conventions = "0.66b0" typing-extensions = ">=4.5.0" [package.extras] -file-configuration = ["opentelemetry-configuration (==0.65b0)"] +file-configuration = ["opentelemetry-configuration (==0.66b0)"] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.65b0" +version = "0.66b0" description = "OpenTelemetry Semantic Conventions" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb"}, - {file = "opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60"}, + {file = "opentelemetry_semantic_conventions-0.66b0-py3-none-any.whl", hash = "sha256:175b19dd98c4473f4f43a2b1df59186fd7b4a48cd3f77cbe03438b6d6fda230a"}, + {file = "opentelemetry_semantic_conventions-0.66b0.tar.gz", hash = "sha256:97a77dce484c54861e7eeff7651fd8a806dd3c30e501dc316730215ec36890e6"}, ] [package.dependencies] -opentelemetry-api = "1.44.0" +opentelemetry-api = "1.45.0" typing-extensions = ">=4.5.0" [[package]] @@ -2638,42 +2678,42 @@ requests = ">=2.0.1,<3.0.0" [[package]] name = "ruff" -version = "0.16.8" +version = "0.16.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38"}, - {file = "ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e"}, - {file = "ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773"}, - {file = "ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f"}, - {file = "ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa"}, - {file = "ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a"}, - {file = "ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b"}, + {file = "ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96"}, + {file = "ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67"}, + {file = "ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588"}, + {file = "ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385"}, + {file = "ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b"}, + {file = "ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284"}, + {file = "ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161"}, ] [[package]] name = "sentry-sdk" -version = "2.70.0" +version = "2.71.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "sentry_sdk-2.70.0-py3-none-any.whl", hash = "sha256:89600e8bb5fc5239cad5c4434217f453204f2207ad29399ea58947460b37a62e"}, - {file = "sentry_sdk-2.70.0.tar.gz", hash = "sha256:27e4b512f47be93136645dbe8f02d430473ee73a389ce430b2b7104baa66cf75"}, + {file = "sentry_sdk-2.71.0-py3-none-any.whl", hash = "sha256:9ed36b4c206048aa67b5ddba0a6e28d59fc55cf10d60f5f7aea2c3917184bf4a"}, + {file = "sentry_sdk-2.71.0.tar.gz", hash = "sha256:7beb27a22f06396f3a05510c3da6ea10e1c79e02e859993c27ce4ff074e296d6"}, ] [package.dependencies] @@ -3760,4 +3800,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "3.11.*" -content-hash = "8886c2739952046692045906293b67f982abb2f848864097d8f667304f50bf8f" +content-hash = "b44e2453e8cd62420e08581f70ddf177b81810ffe12935ff6f92f17db2d104d1" diff --git a/pyproject.toml b/pyproject.toml index b80f6797..4600779a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,11 +21,11 @@ langchain-openai = "^1.6" langchain-text-splitters = "^1.1" nltk = "^3.10.3" pytest = "^9.1.1" -sentry-sdk = "^2.70.0" +sentry-sdk = "^2.71.0" psycopg2-binary = "^2.9.13" -langfuse = "^4.15.4" -opentelemetry-instrumentation-anthropic = "^0.62.3" -opentelemetry-instrumentation-threading = "0.65b0" +langfuse = "^4.15.6" +opentelemetry-instrumentation-anthropic = "^0.62.4" +opentelemetry-instrumentation-threading = "0.66b0" pandas = "^2.2" pgvector = "^0.5.0" @@ -34,7 +34,7 @@ optional = false [tool.poetry.group.dev.dependencies] pytest = "^9.1.1" -ruff = "^0.16.8" +ruff = "^0.16.9" [build-system] requires = ["poetry-core"] From dffc653d1aa199a161c24f0f031446167846f053 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:37:15 +0000 Subject: [PATCH 05/26] Bump openai from 2.48.0 to 3.20.0 Bumps [openai](https://github.com/openai/openai-python) from 2.48.0 to 3.20.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.48.0...v3.20.0) --- updated-dependencies: - dependency-name: openai dependency-version: 3.20.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- poetry.lock | 340 +++++++++++++++++++++++++++++-------------------- pyproject.toml | 2 +- 2 files changed, 206 insertions(+), 136 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6ea8d960..03316a1b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -757,6 +757,29 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<1.0)"] +[[package]] +name = "httpcore2" +version = "2.13.1" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a"}, + {file = "httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103"}, +] + +[package.dependencies] +h11 = ">=0.16" +truststore = ">=0.10" + +[package.extras] +asyncio = ["anyio (>=4.5.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.34.0,<1.0)"] + [[package]] name = "httpx" version = "0.28.1" @@ -794,20 +817,47 @@ files = [ {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] +[[package]] +name = "httpx2" +version = "2.13.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4"}, + {file = "httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa"}, +] + +[package.dependencies] +anyio = {version = ">=4.10", markers = "sys_platform != \"emscripten\""} +httpcore2 = {version = "2.13.1", markers = "sys_platform != \"emscripten\""} +idna = ">=3.18" +truststore = {version = ">=0.10", markers = "sys_platform != \"emscripten\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version < \"3.13\""} + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.2) ; platform_python_implementation != \"CPython\""] +cli = ["click (>=8.4.2)", "pygments (==2.*)", "rich (>=10,<16)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +ws = ["wsproto (>=1.2)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version <= \"3.13\""] + [[package]] name = "idna" -version = "3.15" +version = "3.20" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, - {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, + {file = "idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c"}, + {file = "idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44"}, ] [package.extras] -all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["coverage (>=7.10.0)", "hypothesis (>=6.141.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.16.0)", "ty (>=0.0.37)"] [[package]] name = "iniconfig" @@ -823,121 +873,131 @@ files = [ [[package]] name = "jiter" -version = "0.14.0" +version = "0.17.0" description = "Fast iterable JSON parser." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "jiter-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:02f36a5c700f105ac04a6556fe664a59037a2c200db3b7e88784fac2ddf02531"}, - {file = "jiter-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41eab6c09ceffb6f0fe25e214b3068146edb1eda3649ca2aee2a061029c7ba2e"}, - {file = "jiter-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf4d4c109641f9cfaf4a7b6aebd51654e405cd00fa9ebbf87163b8b97b325aa"}, - {file = "jiter-0.14.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80c7b41a628e6be2213ad0ece763c5f88aa5ee003fa394d58acaaee1f4b8342"}, - {file = "jiter-0.14.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb3dbf7cc0d4dbe73cce307ebe7eefa7f73a7d3d854dd119ea0c243f03e40927"}, - {file = "jiter-0.14.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7054adcdeb06b46efd17b5734f75817a44a2d06d3748e36c3a023a1bb52af9ec"}, - {file = "jiter-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d597cd1bf6790376f3fffc7c708766e57301d99a19314824ea0ccc9c3c70e1e2"}, - {file = "jiter-0.14.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:df63a14878da754427926281626fd3ee249424a186e25a274e78176d42945264"}, - {file = "jiter-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ea73187627bcc5810e085df715e8a99da8bdfd96a7eb36b4b4df700ba6d4c9c"}, - {file = "jiter-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9f541eaf7bb8382367a1a23d6fc3d6aad57f8dd8c18c3c17f838bee20f217220"}, - {file = "jiter-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:107465250de4fce00fdb47166bcd51df8e634e049541174fe3c71848e44f52ce"}, - {file = "jiter-0.14.0-cp310-cp310-win32.whl", hash = "sha256:ffb2a08a406465bb076b7cc1df41d833106d3cf7905076cc73f0cb90078c7d10"}, - {file = "jiter-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb8b682d10cb0cce7ff4c1af7244af7022c9b01ae16d46c357bdd0df13afb25d"}, - {file = "jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2"}, - {file = "jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804"}, - {file = "jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c"}, - {file = "jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e"}, - {file = "jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c"}, - {file = "jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f"}, - {file = "jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373"}, - {file = "jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00"}, - {file = "jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314"}, - {file = "jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c"}, - {file = "jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9"}, - {file = "jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d"}, - {file = "jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842"}, - {file = "jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593"}, - {file = "jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607"}, - {file = "jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844"}, - {file = "jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb"}, - {file = "jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a"}, - {file = "jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01"}, - {file = "jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d"}, - {file = "jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165"}, - {file = "jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3"}, - {file = "jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e"}, - {file = "jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98"}, - {file = "jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3"}, - {file = "jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129"}, - {file = "jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f"}, - {file = "jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057"}, - {file = "jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94"}, - {file = "jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a"}, - {file = "jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1"}, - {file = "jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9"}, - {file = "jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9"}, - {file = "jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db"}, - {file = "jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa"}, - {file = "jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2"}, - {file = "jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985"}, - {file = "jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7"}, - {file = "jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8"}, - {file = "jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f"}, - {file = "jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f"}, - {file = "jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92"}, - {file = "jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab"}, - {file = "jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40"}, - {file = "jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea"}, - {file = "jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f"}, - {file = "jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975"}, - {file = "jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140"}, - {file = "jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9"}, - {file = "jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615"}, - {file = "jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850"}, - {file = "jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9"}, - {file = "jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994"}, - {file = "jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa"}, - {file = "jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5"}, - {file = "jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928"}, - {file = "jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28"}, - {file = "jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de"}, - {file = "jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc"}, - {file = "jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02"}, - {file = "jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611"}, - {file = "jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b"}, - {file = "jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a"}, - {file = "jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a"}, - {file = "jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b"}, - {file = "jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746"}, - {file = "jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310"}, - {file = "jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4"}, - {file = "jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2"}, - {file = "jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560"}, - {file = "jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06"}, - {file = "jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674"}, - {file = "jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588"}, - {file = "jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff"}, - {file = "jiter-0.14.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:85581c4c3e4060fe3424cdfd7f3aa610f2dc5e9dde8b6863358eb68560018472"}, - {file = "jiter-0.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c6279c63849444a4fe9b9abf82e5df0fc7d13dea07f53f084b362485bd1f2bbe"}, - {file = "jiter-0.14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59940ef6ac9f8b34c800838416f105f0503485fa8d71cae99f71d44a7285b01e"}, - {file = "jiter-0.14.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:55bee2b6a2657434984d9144c20cf27ba3b6acd495539539953e447778515efd"}, - {file = "jiter-0.14.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d45fc7ea86a46bd9b5bceb9e8d43e5d10a392378713fb32cf1ce851b4b0d1f8"}, - {file = "jiter-0.14.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:758d19dae7ea4c4da3cbc463dc323d1660e7353144ef17509ff43beab6da5a47"}, - {file = "jiter-0.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32959d7285d1d0deb5a8c913349e476ad9271b384f3e54cca1931c4075f54c6e"}, - {file = "jiter-0.14.0-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:78a4c677fe5689e0e129b39f5affe9210a500b6620ebb0386ebccf5922bee9a6"}, - {file = "jiter-0.14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ae66782ecffb1a266e1a07f5abbfc3832afdd260fc9b478982c3f8e01eba5fa"}, - {file = "jiter-0.14.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:155dab67beac8d66cec9479c93ee2cbe7bfbc67509e5c2860e02ec2d9b0ecca1"}, - {file = "jiter-0.14.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f16b76d7d6aadbbaf7f79a76ff3a51dae14b7ebaaf9c1ba61607784ef51c537c"}, - {file = "jiter-0.14.0-cp39-cp39-win32.whl", hash = "sha256:0fbad7aa06f87e8215d660fc6f05a9b07b58751a29967bbd9c81ff22d21dbe8c"}, - {file = "jiter-0.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:e1765c3ef3ea31fe6e282376a16def1a96f5f11a0235055696c18d9d23ff30cb"}, - {file = "jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211"}, - {file = "jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b"}, - {file = "jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea"}, - {file = "jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577"}, - {file = "jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9"}, - {file = "jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d"}, - {file = "jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016"}, - {file = "jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a"}, - {file = "jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e"}, + {file = "jiter-0.17.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ed1a24005daac667d577402d75a2922f9775a165b146b883ff1ad3602d8be689"}, + {file = "jiter-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b847b18d066c46b3b7ae49d6c94a7634c5e4a8983146ee25562a092000f5e3ad"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b68d3495d95da120651a5628c7ebadee84ed001a1b76e6afc325c42482f15b5"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c1a5336c04a41b1f1cf9572e294aec27cc569767ff73de7bf87a91f0bea7cb9"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b75f85660108965a94be77911a25a253429307294d9415b3c597118977a614de"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32aaaa764604496610a3ad2d98503ae88ccb2fbe769e892ff4533e778e85f708"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:826871c42cebaae22f0a2b5673a4a1a75c851bb2d13b3c17764a630a6b298984"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:00b5a98df3e3a3e8cf7b619f4ac2f8bf975bbf3d95d02c5d17b8dbfe5c8b8245"}, + {file = "jiter-0.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6af5b74073bd25bae695e6d00919f6a9be7ed5a9f8836d981eb1ffe84139e6fb"}, + {file = "jiter-0.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:16dd0c1baf098ae70b8f3616574eb3fedf34e26670b89e16a7e67561f737ed2d"}, + {file = "jiter-0.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:545c36a0f3b2238c242cc9785439d3242a871b7bc39fe3f441bcaa07bf3aa83e"}, + {file = "jiter-0.17.0-cp310-cp310-win32.whl", hash = "sha256:155be7355bdb7ca76ab0961be8982c225f964a5c073a83984183f22391cc29fc"}, + {file = "jiter-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:37150a9e02e869475854fa20b7d0d5e26d18d0f8bc17293999973ff27e99ae7a"}, + {file = "jiter-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1"}, + {file = "jiter-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04"}, + {file = "jiter-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233"}, + {file = "jiter-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599"}, + {file = "jiter-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c"}, + {file = "jiter-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80"}, + {file = "jiter-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb"}, + {file = "jiter-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3"}, + {file = "jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91"}, + {file = "jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812"}, + {file = "jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a"}, + {file = "jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44"}, + {file = "jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31"}, + {file = "jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd"}, + {file = "jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3"}, + {file = "jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8"}, + {file = "jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa"}, + {file = "jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca"}, + {file = "jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8"}, + {file = "jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9"}, + {file = "jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8"}, + {file = "jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7"}, + {file = "jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd"}, + {file = "jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac"}, + {file = "jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5"}, + {file = "jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b"}, + {file = "jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b"}, + {file = "jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9"}, + {file = "jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f"}, + {file = "jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03"}, + {file = "jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8"}, + {file = "jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312"}, + {file = "jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744"}, + {file = "jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768"}, + {file = "jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718"}, + {file = "jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59"}, + {file = "jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1"}, + {file = "jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06"}, + {file = "jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9"}, + {file = "jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc"}, + {file = "jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925"}, + {file = "jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87"}, + {file = "jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2"}, + {file = "jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575"}, + {file = "jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688"}, + {file = "jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec"}, + {file = "jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1"}, + {file = "jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1"}, + {file = "jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b"}, + {file = "jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d"}, + {file = "jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb"}, + {file = "jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a"}, + {file = "jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d"}, + {file = "jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174"}, + {file = "jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23"}, + {file = "jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33"}, + {file = "jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2"}, + {file = "jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39"}, + {file = "jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe"}, + {file = "jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c"}, + {file = "jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2"}, + {file = "jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5"}, + {file = "jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff"}, + {file = "jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12"}, ] [[package]] @@ -1042,7 +1102,7 @@ pydantic-settings = ">=2.10.1,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" requests = ">=2.32.5,<3.0.0" sqlalchemy = ">=1.4.0,<3.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" [[package]] name = "langchain-core" @@ -1064,7 +1124,7 @@ langsmith = ">=0.3.45,<1.0.0" packaging = ">=23.2.0" pydantic = ">=2.7.4,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" typing-extensions = ">=4.7.0,<5.0.0" uuid-utils = ">=0.12.0,<1.0" @@ -1102,7 +1162,7 @@ files = [ httpx = ">=0.28.0" langchain-core = ">=0.3.34,<2.0.0" langchain-openai = ">=0.3.11" -numpy = ">=1.26.4,<2.0.2 || >2.0.2" +numpy = ">=1.26.4,!=2.0.2" pinecone = {version = ">=6.0.0,<8.0.0", extras = ["asyncio"]} pydantic = ">=2.11.1" simsimd = ">=5.9.11" @@ -1425,31 +1485,28 @@ files = [ [[package]] name = "openai" -version = "2.48.0" +version = "3.20.0" description = "The official Python library for the openai API" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "openai-2.48.0-py3-none-any.whl", hash = "sha256:c98df30aaaf93c51979f64d3e7c5b76464f8be0173368266229eb8fe6bd30f2c"}, - {file = "openai-2.48.0.tar.gz", hash = "sha256:231b1e7661dda14574986c2f71451e9d584b7fe69e0ee6480e12ed090b48fc16"}, + {file = "openai-3.20.0-py3-none-any.whl", hash = "sha256:fb1b109e3423ed51c6ade96bbb0fffddae39c8e64bb9b6e501659bcbaa5ad30c"}, + {file = "openai-3.20.0.tar.gz", hash = "sha256:5e5dc0a3c0a9362e69cd35a65d04580a73a66eb39d5abeb78881b6190ca5d98c"}, ] [package.dependencies] -anyio = ">=3.5.0,<5" -distro = ">=1.7.0,<2" -httpx = ">=0.23.0,<1" -jiter = ">=0.10.0,<1" -pydantic = ">=1.9.0,<3" +anyio = ">=4.10.0,<5" +httpx2 = ">=2.12.0,<3" +jiter = ">=0.16.0,<1" +pydantic = ">=1.10.13,<2.0 || >=2.4.dev0,<3" sniffio = "*" -tqdm = ">4" typing-extensions = ">=4.14,<5" [package.extras] -aiohttp = ["aiohttp (>=3.14.1) ; python_version >= \"3.10\"", "httpx-aiohttp (>=0.1.9) ; python_version >= \"3.10\""] -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)"] -httpx2 = ["anyio (>=4.10.0,<5) ; python_version >= \"3.10\"", "httpx (>=0.25.1,<1) ; python_version >= \"3.10\"", "httpx2 (>=2.7.0,<3) ; python_version >= \"3.10\""] +aiohttp = ["aiohttp (>=3.14.3)"] +bedrock = ["botocore (>=1.40.0,<2)", "urllib3 (>=2.7.0,<3)"] +datalib = ["numpy (>=1)", "pandas (>=1.2.3)"] realtime = ["websockets (>=13,<16)"] voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] @@ -3054,6 +3111,19 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -3760,4 +3830,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "3.11.*" -content-hash = "8886c2739952046692045906293b67f982abb2f848864097d8f667304f50bf8f" +content-hash = "c9cc186b6064f6d4a242742363d26161e6d45f5fcbf672a135463eb83ea39f1c" diff --git a/pyproject.toml b/pyproject.toml index b80f6797..d0a86af0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ requires-poetry = ">=2.3.2" [tool.poetry.dependencies] python = "3.11.*" -openai = "^2.48" +openai = "^3.20" python-dotenv = "^1.2.3" anthropic = "^0.119.0" From 8bb69616b968950ffc60425aaccc2e7638fb83ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:37:38 +0000 Subject: [PATCH 06/26] Bump pandas from 2.3.3 to 3.0.6 Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.3.3 to 3.0.6. - [Release notes](https://github.com/pandas-dev/pandas/releases) - [Commits](https://github.com/pandas-dev/pandas/compare/v2.3.3...v3.0.6) --- updated-dependencies: - dependency-name: pandas dependency-version: 3.0.6 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- poetry.lock | 196 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 95 insertions(+), 103 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6ea8d960..db8257f2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1042,7 +1042,7 @@ pydantic-settings = ">=2.10.1,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" requests = ">=2.32.5,<3.0.0" sqlalchemy = ">=1.4.0,<3.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" [[package]] name = "langchain-core" @@ -1064,7 +1064,7 @@ langsmith = ">=0.3.45,<1.0.0" packaging = ">=23.2.0" pydantic = ">=2.7.4,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" typing-extensions = ">=4.7.0,<5.0.0" uuid-utils = ">=0.12.0,<1.0" @@ -1102,7 +1102,7 @@ files = [ httpx = ">=0.28.0" langchain-core = ">=0.3.34,<2.0.0" langchain-openai = ">=0.3.11" -numpy = ">=1.26.4,<2.0.2 || >2.0.2" +numpy = ">=1.26.4,!=2.0.2" pinecone = {version = ">=6.0.0,<8.0.0", extras = ["asyncio"]} pydantic = ">=2.11.1" simsimd = ">=5.9.11" @@ -1729,99 +1729,102 @@ files = [ [[package]] name = "pandas" -version = "2.3.3" +version = "3.0.6" description = "Powerful data structures for data analysis, time series, and statistics" optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, - {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, - {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1"}, - {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838"}, - {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250"}, - {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4"}, - {file = "pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826"}, - {file = "pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523"}, - {file = "pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45"}, - {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66"}, - {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b"}, - {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791"}, - {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151"}, - {file = "pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c"}, - {file = "pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53"}, - {file = "pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35"}, - {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908"}, - {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89"}, - {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98"}, - {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084"}, - {file = "pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b"}, - {file = "pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713"}, - {file = "pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8"}, - {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d"}, - {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac"}, - {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c"}, - {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493"}, - {file = "pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee"}, - {file = "pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5"}, - {file = "pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21"}, - {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78"}, - {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110"}, - {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86"}, - {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc"}, - {file = "pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0"}, - {file = "pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593"}, - {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c"}, - {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b"}, - {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6"}, - {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3"}, - {file = "pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5"}, - {file = "pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec"}, - {file = "pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7"}, - {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450"}, - {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5"}, - {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788"}, - {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87"}, - {file = "pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2"}, - {file = "pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8"}, - {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff"}, - {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29"}, - {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73"}, - {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9"}, - {file = "pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa"}, - {file = "pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b"}, +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "pandas-3.0.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:085e3786ae6b2e82b406266bce36690f72b9dc1421903ba9296b2981a9fcf586"}, + {file = "pandas-3.0.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d7564d86a94c2eb8ab290b07f63ddaae5c032fa53897c29a2ff2197d43aee8af"}, + {file = "pandas-3.0.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7c0afdcaf6661d795fcefc2f647ddd1136f62cdc153fba177c685d97a87808"}, + {file = "pandas-3.0.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47121f9571503f724c9b93e297ab6254ac99c77adf5e9ed085ea419fd585c258"}, + {file = "pandas-3.0.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:994a79608263fe1c14cc48ffa7300e2b834b7d1cb406ffe96a08828cb0cdd79b"}, + {file = "pandas-3.0.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a3a22e07fe75347eaacc75b0e85297947af4fba6b4aae23916bd8b6828d0bba3"}, + {file = "pandas-3.0.6-cp311-cp311-win_amd64.whl", hash = "sha256:2e5fa32ff162dfdbc280157d664f44d23049ae414725af9676df339c501d82cd"}, + {file = "pandas-3.0.6-cp311-cp311-win_arm64.whl", hash = "sha256:5e75072773c1b2f7cb63faa3a6f562aede11f3976f68ed34cb538bc091a28171"}, + {file = "pandas-3.0.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dac2d65e9087e8e7b5a45fe15c4920911a221df061ab629943ce016489145c7"}, + {file = "pandas-3.0.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9dab635a549e58a053c7b0fa054dc0bd7be22f0ed9a720f4a85d5fb993276172"}, + {file = "pandas-3.0.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3dccb584123b399c07562ac4d62543e90ede49ddf8ce3c13ffc64cbe828c281"}, + {file = "pandas-3.0.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0704044b676496b8350e023b09f174a26772456c974a2b11c36bebb558c9490d"}, + {file = "pandas-3.0.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e7c1905ef02c3d6d43d9dbd5b6ccb4da4870a0b0c821bbc103fbdb6f3ad2707b"}, + {file = "pandas-3.0.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:569e114072b24fc4970c12e2b4bab252671668a40b324318903380cab0254c0c"}, + {file = "pandas-3.0.6-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:2a8fc94be2ee5f1d86f97aacd8cc566f81680b6498e76f3007421bb5d98151bf"}, + {file = "pandas-3.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:3ef908d28590b3f42d7070e7ad8f9b34b442b260b7f3c1afb57e0040c58cdb1b"}, + {file = "pandas-3.0.6-cp312-cp312-win_arm64.whl", hash = "sha256:f4e7c52eb108d752e7592268108fd3e98efd76d83a3125cdd06c621c2e44359b"}, + {file = "pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ae8073aed8e21d1a7fe263dcdc6840743549722a6738198a0a46000fa9476f2"}, + {file = "pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:60d81f9e1799b36f3739e7fff44d1fbb2e8fd5a271b3863e03de9715fccda0fa"}, + {file = "pandas-3.0.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:097090508a1dd335013d39106fc10b20f4fd4a171638e47b77d55798ed9dab6c"}, + {file = "pandas-3.0.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e92d9fa834c7d877130027cddc0cad8dcff97c1f6cca26bd6310f847228b658"}, + {file = "pandas-3.0.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b27c8d890e4aa2171437ae2a39de1d215e674158e4865c4023a8b31c932513b2"}, + {file = "pandas-3.0.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8029ec0f1f89e4f985929ce1f6626dabf3140d61a4e9c1215afdab34eaf9a5d"}, + {file = "pandas-3.0.6-cp313-cp313-win_amd64.whl", hash = "sha256:f3ce8a6968045481e91a3990e797e348ce13db45ee164a7095bbc824e26c09dd"}, + {file = "pandas-3.0.6-cp313-cp313-win_arm64.whl", hash = "sha256:cc39303913e2ea129915670de5d1c9fbd647f543bb72e5543bac8baa94e9e42f"}, + {file = "pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1"}, + {file = "pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729"}, + {file = "pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34"}, + {file = "pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1"}, + {file = "pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de"}, + {file = "pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c"}, + {file = "pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553"}, + {file = "pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c"}, + {file = "pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514"}, + {file = "pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60"}, + {file = "pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541"}, + {file = "pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965"}, + {file = "pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7"}, + {file = "pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44"}, + {file = "pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630"}, + {file = "pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a"}, + {file = "pandas-3.0.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:50c44cbf5820b6b91a5f74aae04972472aefadd3cd9fbd1010409d85528bd570"}, + {file = "pandas-3.0.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:eb6900de08ac85f93ac4948aa6b80842eba555875337b8359035ac9c43e92d34"}, + {file = "pandas-3.0.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e25e2e1adee99ddfada6f7206a79ae8e9c8a8861b0e3eaaba165006d3eef18e"}, + {file = "pandas-3.0.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ff44b2cb51cbd691c91f92c4ea6c71e34003f239ebd67c2e857dc898466b49c"}, + {file = "pandas-3.0.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5edd0a7abb0986ecce1ac81f56d99b6763f86aa6946dceb6c661224f90af5a19"}, + {file = "pandas-3.0.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1bcb3e9ed29e74a7439cedff9e2aefd3ea65de84d7de9ccb6c194192541bd60e"}, + {file = "pandas-3.0.6-cp315-cp315-win_amd64.whl", hash = "sha256:253e12cb9081b0afbac607920f6142975966bc315135e09de275fdbaa415d2de"}, + {file = "pandas-3.0.6-cp315-cp315-win_arm64.whl", hash = "sha256:97274c9adf6255bb48c620cd6959805efa7f09ea2167f0e0ae006a448cd2fca7"}, + {file = "pandas-3.0.6-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:265f562fdd1079f69f3de96dd425c3405224038c0af4f920c54bd240ee2c4640"}, + {file = "pandas-3.0.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c6e4aae3e9bea26c6c9a20d88d96c86ec4a99b4db5fd516bcb4e829ab2c0ee36"}, + {file = "pandas-3.0.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a77a1a44e4d88f1c6a2a64d3eb12efec8420875722e14279800b173a7c7c2804"}, + {file = "pandas-3.0.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fa853a12e0b70927e2b1ee00d56d2224ec9cbb4b9d58348b5ad52d2f21150e"}, + {file = "pandas-3.0.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c826e9babb7790142c399f58599d8de679bea059d7b39c5b6efa2096fac37266"}, + {file = "pandas-3.0.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8fe77b408d82e2615674dfed62533b95e18a03610573877422aada4f625d4947"}, + {file = "pandas-3.0.6-cp315-cp315t-win_amd64.whl", hash = "sha256:83e91d15738d7783c050197cef2f2cf82fc6353dae9865aa87ed1fa16aa4d55a"}, + {file = "pandas-3.0.6-cp315-cp315t-win_arm64.whl", hash = "sha256:963ca21199097a84c7827c4678b04e30833084fbf8ef44fde3fa7180a29f8fa0"}, + {file = "pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10"}, ] [package.dependencies] -numpy = {version = ">=1.23.2", markers = "python_version == \"3.11\""} +numpy = {version = ">=1.26.0", markers = "python_version < \"3.14\""} python-dateutil = ">=2.8.2" -pytz = ">=2020.1" -tzdata = ">=2022.7" +tzdata = {version = "*", markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\""} [package.extras] -all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] -aws = ["s3fs (>=2022.11.0)"] -clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] -compression = ["zstandard (>=0.19.0)"] -computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] -consortium-standard = ["dataframe-api-compat (>=0.1.7)"] -excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] -feather = ["pyarrow (>=10.0.1)"] -fss = ["fsspec (>=2022.11.0)"] -gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] -hdf5 = ["tables (>=3.8.0)"] -html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] -mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] -output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] -parquet = ["pyarrow (>=10.0.1)"] -performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] -plot = ["matplotlib (>=3.6.3)"] -postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] -pyarrow = ["pyarrow (>=10.0.1)"] -spss = ["pyreadstat (>=1.2.0)"] -sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] -test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] -xml = ["lxml (>=4.9.2)"] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)", "beautifulsoup4 (>=4.12.3)", "bottleneck (>=1.4.2)", "fastparquet (>=2024.11.0)", "fsspec (>=2024.10.0)", "gcsfs (>=2024.10.0)", "html5lib (>=1.1)", "hypothesis (>=6.116.0)", "jinja2 (>=3.1.5)", "lxml (>=5.3.0)", "matplotlib (>=3.9.3)", "numba (>=0.60.0)", "numexpr (>=2.10.2)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "psycopg2 (>=2.9.10)", "pyarrow (>=13.0.0)", "pyiceberg (>=0.8.1)", "pymysql (>=1.1.1)", "pyreadstat (>=1.2.8)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)", "python-calamine (>=0.3.0)", "pytz (>=2020.1)", "pyxlsb (>=1.0.10)", "qtpy (>=2.4.2)", "s3fs (>=2024.10.0)", "scipy (>=1.14.1)", "tables (>=3.10.1)", "tabulate (>=0.9.0)", "xarray (>=2024.10.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)", "zstandard (>=0.23.0)"] +aws = ["s3fs (>=2024.10.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.4.2)"] +compression = ["zstandard (>=0.23.0)"] +computation = ["scipy (>=1.14.1)", "xarray (>=2024.10.0)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "python-calamine (>=0.3.0)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)"] +feather = ["pyarrow (>=13.0.0)"] +fss = ["fsspec (>=2024.10.0)"] +gcp = ["gcsfs (>=2024.10.0)"] +hdf5 = ["tables (>=3.10.1)"] +html = ["beautifulsoup4 (>=4.12.3)", "html5lib (>=1.1)", "lxml (>=5.3.0)"] +iceberg = ["pyiceberg (>=0.8.1)"] +mysql = ["SQLAlchemy (>=2.0.36)", "pymysql (>=1.1.1)"] +output-formatting = ["jinja2 (>=3.1.5)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=13.0.0)"] +performance = ["bottleneck (>=1.4.2)", "numba (>=0.60.0)", "numexpr (>=2.10.2)"] +plot = ["matplotlib (>=3.9.3)"] +postgresql = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "psycopg2 (>=2.9.10)"] +pyarrow = ["pyarrow (>=13.0.0)"] +spss = ["pyreadstat (>=1.2.8)"] +sql-other = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)"] +test = ["hypothesis (>=6.116.0)", "pytest (>=8.3.4,<9.1)", "pytest-xdist (>=3.6.1)"] +timezone = ["pytz (>=2020.1)"] +xml = ["lxml (>=5.3.0)"] [[package]] name = "pgvector" @@ -2380,18 +2383,6 @@ files = [ [package.extras] cli = ["click (>=5.0)"] -[[package]] -name = "pytz" -version = "2026.3.post1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815"}, - {file = "pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d"}, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -3088,6 +3079,7 @@ description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] +markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\"" files = [ {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, {file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"}, @@ -3760,4 +3752,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "3.11.*" -content-hash = "8886c2739952046692045906293b67f982abb2f848864097d8f667304f50bf8f" +content-hash = "d2832d4c8e7c7d40356294181ef1b14f1bafc99eeb8d2b64e9b367612f4b23a9" diff --git a/pyproject.toml b/pyproject.toml index b80f6797..c50e6dd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ psycopg2-binary = "^2.9.13" langfuse = "^4.15.4" opentelemetry-instrumentation-anthropic = "^0.62.3" opentelemetry-instrumentation-threading = "0.65b0" -pandas = "^2.2" +pandas = "^3.0" pgvector = "^0.5.0" [tool.poetry.group.dev] From aeefb2d9fe0eaf2ffca22b22c15bb0c1575e02d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:38:00 +0000 Subject: [PATCH 07/26] Bump anthropic from 0.119.0 to 1.9.0 Bumps [anthropic](https://github.com/anthropics/anthropic-sdk-python) from 0.119.0 to 1.9.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.119.0...v1.9.0) --- updated-dependencies: - dependency-name: anthropic dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- poetry.lock | 94 ++++++++++++++++++++++++++++++++++++++++---------- pyproject.toml | 2 +- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6ea8d960..d7050281 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -199,32 +199,31 @@ files = [ [[package]] name = "anthropic" -version = "0.119.0" +version = "1.9.0" description = "The official Python library for the anthropic API" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "anthropic-0.119.0-py3-none-any.whl", hash = "sha256:2b39107077489d19d7c66000e9d9263293d431550fcf6b543ead785c6ac85962"}, - {file = "anthropic-0.119.0.tar.gz", hash = "sha256:40a81b094ae2a0056a77771257943b19bd46edce1326e22f85c2794492097f0a"}, + {file = "anthropic-1.9.0-py3-none-any.whl", hash = "sha256:a7fe05a9e4b72113b934501698df5d56484ac89f05677f0f793f8bfddb15cfff"}, + {file = "anthropic-1.9.0.tar.gz", hash = "sha256:da5191a0af0b853a797fc0c6b09fc5b05cb5d9da063ba80fdd03c736abe81b6c"}, ] [package.dependencies] -anyio = ">=3.5.0,<5" -distro = ">=1.7.0,<2" +anyio = ">=4.1.0,<5" docstring-parser = ">=0.15,<1" -httpx = ">=0.25.0,<1" +httpx2 = ">=2.0.0,<3" jiter = ">=0.4.0,<1" -pydantic = ">=1.9.0,<3" -sniffio = "*" +pydantic = ">=1.10.0,<3" +sniffio = ">=1,<2" typing-extensions = ">=4.14,<5" [package.extras] -aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9,<1)"] -aws = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] -bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] +aiohttp = ["aiohttp (>=3.10.0,<4)"] +aws = ["boto3 (>=1.28.57,<2)", "botocore (>=1.31.57,<2)"] +bedrock = ["boto3 (>=1.28.57,<2)", "botocore (>=1.31.57,<2)"] google-cloud = ["google-auth[requests] (>=2,<3)"] -mcp = ["mcp (>=1.0) ; python_version >= \"3.10\""] +mcp = ["mcp (>=1.0,<3) ; python_version >= \"3.10\""] vertex = ["google-auth[requests] (>=2,<3)"] webhooks = ["standardwebhooks (>=1.0.1,<2)"] @@ -757,6 +756,28 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<1.0)"] +[[package]] +name = "httpcore2" +version = "2.3.0" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415"}, + {file = "httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924"}, +] + +[package.dependencies] +h11 = ">=0.16" +truststore = ">=0.10" + +[package.extras] +asyncio = ["anyio (>=4.5.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + [[package]] name = "httpx" version = "0.28.1" @@ -794,6 +815,31 @@ files = [ {file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"}, ] +[[package]] +name = "httpx2" +version = "2.3.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7"}, + {file = "httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9"}, +] + +[package.dependencies] +anyio = "*" +httpcore2 = "2.3.0" +idna = "*" +truststore = ">=0.10" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<15)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0) ; python_version <= \"3.13\""] + [[package]] name = "idna" version = "3.15" @@ -1042,7 +1088,7 @@ pydantic-settings = ">=2.10.1,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" requests = ">=2.32.5,<3.0.0" sqlalchemy = ">=1.4.0,<3.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" [[package]] name = "langchain-core" @@ -1064,7 +1110,7 @@ langsmith = ">=0.3.45,<1.0.0" packaging = ">=23.2.0" pydantic = ">=2.7.4,<3.0.0" pyyaml = ">=5.3.0,<7.0.0" -tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" +tenacity = ">=8.1.0,!=8.4.0,<10.0.0" typing-extensions = ">=4.7.0,<5.0.0" uuid-utils = ">=0.12.0,<1.0" @@ -1102,7 +1148,7 @@ files = [ httpx = ">=0.28.0" langchain-core = ">=0.3.34,<2.0.0" langchain-openai = ">=0.3.11" -numpy = ">=1.26.4,<2.0.2 || >2.0.2" +numpy = ">=1.26.4,!=2.0.2" pinecone = {version = ">=6.0.0,<8.0.0", extras = ["asyncio"]} pydantic = ">=2.11.1" simsimd = ">=5.9.11" @@ -3054,6 +3100,18 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -3760,4 +3818,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "3.11.*" -content-hash = "8886c2739952046692045906293b67f982abb2f848864097d8f667304f50bf8f" +content-hash = "99f87509aae864da71c2df9da7c1762989ad49f9e630685872ddeefa94421bcc" diff --git a/pyproject.toml b/pyproject.toml index b80f6797..339d39d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ requires-poetry = ">=2.3.2" python = "3.11.*" openai = "^2.48" python-dotenv = "^1.2.3" -anthropic = "^0.119.0" +anthropic = "^1.9.0" langchain-pinecone = "^0.2.13" langchain-core = "^1.6" From 01ddcf1682535cdc9060d884edd9240dc57ea82c Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Tue, 6 Oct 2026 20:05:46 +0100 Subject: [PATCH 08/26] Sync bun.lock with elysia bump --- bun.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index bfec56c0..e695f9ea 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@elysiajs/html": "^1.4.0", "@openfn/adaptor-apis": "^0.3.0", "@sentry/bun": "^10.60.0", - "elysia": "1.4.27", + "elysia": "1.4.29", "jsdoc-babel": "^0.5.0", }, "devDependencies": { @@ -425,7 +425,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.377", "", {}, "sha512-cH1jZgJHoezfTnKfKwnScpHywTFVnJUNITDPREFdhNjiuD502+QFpG0Qk7G8jhsV/f+CEAFlIrzP1fT+IMb92g=="], - "elysia": ["elysia@1.4.27", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg=="], + "elysia": ["elysia@1.4.29", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-GwMRGGwSdjfPt+w3LA0fqTuYJtS8uVRJicvoar98/HrO5qdFKDc9CwjIb6Kja+v39lkY+58hr2JvdR9jQzlUuA=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], From 907558abdd583fac8d561cd6b3c87d0b9df74d55 Mon Sep 17 00:00:00 2001 From: doc-han Date: Wed, 7 Oct 2026 08:33:59 +0000 Subject: [PATCH 09/26] feat: revert changes to entry file --- services/entry.py | 69 +++++++++++-------------- services/fork_server.py | 109 ---------------------------------------- 2 files changed, 30 insertions(+), 148 deletions(-) delete mode 100644 services/fork_server.py diff --git a/services/entry.py b/services/entry.py index daf8f597..238f476e 100644 --- a/services/entry.py +++ b/services/entry.py @@ -9,15 +9,17 @@ load_dotenv() +# Langfuse: init after load_dotenv so env vars are available, before any Anthropic client is created from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor from opentelemetry.instrumentation.threading import ThreadingInstrumentor +AnthropicInstrumentor().instrument() +ThreadingInstrumentor().instrument() + from langfuse import Langfuse from langfuse.span_filter import is_default_export_span from langfuse_util import mask_secrets -langfuse = None # set by bootstrap() - def _should_export_span(span): """Drop spans marked as tracing-disabled (user has not opted in).""" @@ -27,6 +29,20 @@ def _should_export_span(span): return is_default_export_span(span) +langfuse = Langfuse( + should_export_span=_should_export_span, + mask=mask_secrets, + release=os.getenv("APOLLO_VERSION", "unknown"), +) + +env = os.getenv('ENVIRONMENT', 'unknown') +trace_rates = { + 'development': 1, + 'staging': 0.05, + 'production': 0.03, + 'unknown': 0.0, + } + def _scrub_event(event: dict, _hint: dict) -> dict: """Mask keys in what Sentry is about to send. @@ -38,41 +54,17 @@ def _scrub_event(event: dict, _hint: dict) -> dict: return mask_secrets(event) -def bootstrap() -> None: - global langfuse - - AnthropicInstrumentor().instrument() - ThreadingInstrumentor().instrument() - - langfuse = Langfuse( - should_export_span=_should_export_span, - mask=mask_secrets, - release=os.getenv("APOLLO_VERSION", "unknown"), - ) - - env = os.getenv("ENVIRONMENT", "unknown") - trace_rates = { - "development": 1, - "staging": 0.05, - "production": 0.03, - "unknown": 0.0, - } - - sentry_sdk.init( - dsn=os.getenv("SENTRY_DSN"), - environment=env, - sample_rate=1.0, - traces_sample_rate=trace_rates.get(env, 0.0), - enable_tracing=True, - auto_enabling_integrations=False, - before_send=_scrub_event, - # before_send covers error events only, and tracing is on. - before_send_transaction=_scrub_event, - ) - - -if os.environ.get("APOLLO_FORK_PRELOAD") != "1": - bootstrap() +sentry_sdk.init( + dsn=os.getenv('SENTRY_DSN'), + environment=env, + sample_rate=1.0, + traces_sample_rate=trace_rates.get(env, 0.0), + enable_tracing=True, + auto_enabling_integrations=False, + before_send=_scrub_event, + # before_send covers error events only, and tracing is on. + before_send_transaction=_scrub_event, +) # At or above this the failure is ours; below it the caller sent something we # correctly refused. @@ -164,8 +156,7 @@ def call( code=500, message=str(e), type="INTERNAL_ERROR", ).to_dict() - if langfuse is not None: - langfuse.flush() + langfuse.flush() return _finish(result, output_path) diff --git a/services/fork_server.py b/services/fork_server.py deleted file mode 100644 index 9aecdce7..00000000 --- a/services/fork_server.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -import sys - -# macOS aborts a forked child that touches an objc framework unless this is set -# before interpreter start, so re-exec once with it in the env. No-op on Linux. -# [To be removed] -if sys.platform == "darwin" and os.environ.get("OBJC_DISABLE_INITIALIZE_FORK_SAFETY") != "YES": - os.environ["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES" - os.execvpe(sys.executable, [sys.executable, *sys.argv], os.environ) - -# Tell entry.py to import its heavy modules but defer bootstrap() to each child. -os.environ["APOLLO_FORK_PRELOAD"] = "1" - -import argparse # noqa: E402 -import importlib # noqa: E402 -import json # noqa: E402 -import signal # noqa: E402 -import socket # noqa: E402 -import traceback # noqa: E402 - -import entry # noqa: E402 - - -def _preload_services() -> list[str]: - """Import an opt-in allowlist (APOLLO_FORK_PRELOAD_SERVICES) so children - inherit their deps. Not every service: some do I/O at import and would hang - the parent; entry.call() imports the rest on demand in the child.""" - names = [n.strip() for n in os.environ.get("APOLLO_FORK_PRELOAD_SERVICES", "").split(",") if n.strip()] - loaded = [] - for name in names: - try: - importlib.import_module(f"{name}.{name}") - loaded.append(name) - except Exception as e: - print(f"fork_server: could not preload {name}: {e}", file=sys.stderr, flush=True) - return loaded - - -def _run_child(conn: socket.socket, req: dict) -> None: - os.dup2(conn.fileno(), 1) - os.dup2(conn.fileno(), 2) - sys.stdout = os.fdopen(1, "w", buffering=1) - sys.stderr = os.fdopen(2, "w", buffering=1) - - sys.stdout.write(f"PID:{os.getpid()}\n") # first, so the bridge can cancel this child - sys.stdout.flush() - - code = 0 - try: - entry.bootstrap() - entry.call( - service=req["service"], - input_path=req.get("input"), - output_path=req.get("output"), - apollo_port=req.get("port"), - ) - except Exception: - traceback.print_exc() - code = 1 - finally: - sys.stdout.write(f"EXIT:{code}\n") - sys.stdout.flush() - os._exit(code) - - -def main() -> None: - ap = argparse.ArgumentParser() - ap.add_argument("--socket", required=True) - args = ap.parse_args() - - signal.signal(signal.SIGCHLD, signal.SIG_IGN) # auto-reap children - - loaded = _preload_services() - - if os.path.exists(args.socket): - os.unlink(args.socket) - srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - srv.bind(args.socket) - srv.listen(128) - print(f"READY services={len(loaded)}", flush=True) # handshake the bridge waits for - - while True: - try: - conn, _ = srv.accept() - except InterruptedError: - continue - line = b"" - while not line.endswith(b"\n"): - chunk = conn.recv(65536) - if not chunk: - break - line += chunk - if not line: - conn.close() - continue - try: - req = json.loads(line) - except json.JSONDecodeError: - conn.close() - continue - - if os.fork() == 0: - srv.close() # the child must not hold the listening socket - _run_child(conn, req) # never returns - conn.close() # parent drops its copy; the child owns the reply - - -if __name__ == "__main__": - main() From f40a4988508af8dbfd71564120b71fbad58ab97d Mon Sep 17 00:00:00 2001 From: doc-han Date: Wed, 7 Oct 2026 09:06:44 +0000 Subject: [PATCH 10/26] feat: finalize fork bridge & macos deps --- .env.example | 6 ++ platform/src/bridge.fork.ts | 186 ++++++++++++++++++++++++++++++++++ platform/src/bridge.ts | 197 +----------------------------------- services/entry.fork.py | 129 +++++++++++++++++++++++ 4 files changed, 323 insertions(+), 195 deletions(-) create mode 100644 platform/src/bridge.fork.ts create mode 100644 services/entry.fork.py diff --git a/.env.example b/.env.example index 8f27f4bb..a3f087b1 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,9 @@ LANGFUSE_BASE_URL=https://cloud.langfuse.com # ZILLIZ_URI = https://in01-XXXXXXXXXXXXX.aws-us-west-2.vectordb.zillizcloud.com:XXXXX # ZILLIZ_TOKEN =db_admin:password (or ApiKey) +# Warm fork-server (see services/entry.fork.py). Flip to "true" to use it. +# APOLLO_FORK_SERVER=true +# macOS only: required when APOLLO_FORK_SERVER=true, or a forked child that touches +# an Objective-C framework aborts. No-op on Linux, safe to leave set everywhere. +# OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES + diff --git a/platform/src/bridge.fork.ts b/platform/src/bridge.fork.ts new file mode 100644 index 00000000..c3c0e7ea --- /dev/null +++ b/platform/src/bridge.fork.ts @@ -0,0 +1,186 @@ +import readline from "node:readline"; +import net from "node:net"; +import path from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; +import { rm } from "node:fs/promises"; +import { getInternalToken } from "./auth/internal-token"; +import { + emptyResult, + malformedResult, + subprocessCancelled, + subprocessFailed, + subprocessKilled, + subprocessSpawnFailed, +} from "./util/errors"; +import pkg from "../../package.json"; + +// Only these lines are forwarded to the caller (mirrors bridge.ts). +const LOG_LINE = /^(INFO|DEBUG|ERROR|WARNING):/; + +export const USE_FORK_SERVER = + (process.env.APOLLO_FORK_SERVER ?? "").toLowerCase() === "true"; + +const onLines = (stream: NodeJS.ReadableStream, fn: (line: string) => void) => + readline.createInterface({ input: stream, crlfDelay: Infinity }).on("line", fn); + +// --- the warm master: spawned once, forks a child per request ---------------- + +type Master = { proc: ChildProcess; socketPath: string }; +let master: Promise | null = null; +let masterProc: ChildProcess | null = null; + +// Singleton; a failed start clears the slot so the next request retries. +function forkMaster(): Promise { + return (master ??= startMaster().catch((err) => { + master = null; + throw err; + })); +} + +function startMaster(): Promise { + const socketPath = path.resolve(`tmp/fork_server-${process.pid}.sock`); + const proc = spawn( + "poetry", + ["run", "python", "services/entry.fork.py", "--socket", socketPath], + { + env: { + ...process.env, + APOLLO_INTERNAL_TOKEN: getInternalToken(), + APOLLO_VERSION: pkg.version, + }, + } + ); + masterProc = proc; + onLines(proc.stderr!, (line) => console.error(`fork-server: ${line}`)); + + return new Promise((resolve, reject) => { + const fail = (err: Error) => (clearTimeout(timer), reject(subprocessSpawnFailed("fork_server", err))); + const timer = setTimeout(() => fail(new Error("not READY in time")), 60_000); + + onLines(proc.stdout!, (line) => { + console.log(`fork-server: ${line}`); + if (line.startsWith("READY")) { + clearTimeout(timer); + resolve({ proc, socketPath }); + } + }); + proc.on("error", fail); + proc.on("exit", (code, sig) => { + clearTimeout(timer); + console.error(`fork-server exited (code=${code} signal=${sig})`); + master = masterProc = null; // next request starts a fresh one + rm(socketPath).catch(() => {}); + }); + }); +} + +// Don't leave an orphaned master when the server process goes. +process.once("exit", () => masterProc?.kill("SIGTERM")); + +// --- per-request: ask the master to fork a child, stream it back ------------- + +export async function runForked( + scriptName: string, + port: number, + inputPath: string, + outputPath: string, + onLog?: (str: string) => void, + onEvent?: (type: string, payload: any) => void, + signal?: AbortSignal +): Promise { + const cleanup = () => + Promise.all([rm(inputPath).catch(() => {}), rm(outputPath).catch(() => {})]); + + let active: Master; + try { + active = await forkMaster(); + } catch (err) { + await cleanup(); + throw err; + } + + return new Promise((resolve, reject) => { + const conn = net.createConnection({ path: active.socketPath }); + let childPid: number | null = null; + let exitCode: number | null = null; + let cancelled = false; + let hardKill: ReturnType | undefined; + + const killChild = () => { + if (childPid == null) return; // killed once the PID: line arrives + try { + process.kill(childPid, "SIGTERM"); + } catch {} + hardKill = setTimeout(() => { + try { + process.kill(childPid!, "SIGKILL"); + } catch {} + }, 5_000); + hardKill.unref?.(); + }; + + const onAbort = () => { + cancelled = true; + console.warn(`cancelling ${scriptName}: client went away`); + killChild(); + conn.destroy(); + }; + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + conn.on("connect", () => + conn.write(JSON.stringify({ service: scriptName, input: inputPath, output: outputPath, port }) + "\n") + ); + + // The child's stream: PID:/EXIT: control lines around the service's own stdout. + onLines(conn, (line) => { + if (line.startsWith("PID:")) { + childPid = Number(line.slice(4)) || null; + if (cancelled) killChild(); + } else if (line.startsWith("EXIT:")) { + exitCode = Number(line.slice(5)); + } else if (LOG_LINE.test(line)) { + console.log(line); + onLog?.(line); + } else if (line.startsWith("EVENT:")) { + const [, type, ...rest] = line.split(":"); + let payload: any = rest.join(":"); + try { + payload = JSON.parse(payload); + } catch { + // not json, forward the raw string + } + onEvent?.(type, payload); + } + }); + + const settle = async (finish: () => void) => { + if (hardKill) clearTimeout(hardKill); + signal?.removeEventListener("abort", onAbort); + await cleanup(); + finish(); + }; + + conn.on("error", (err) => + settle(() => reject(subprocessSpawnFailed(scriptName, err))) + ); + + conn.on("close", async () => { + const text = await Bun.file(outputPath).text().catch(() => ""); + await settle(() => { + if (cancelled) return reject(subprocessCancelled(scriptName, "SIGTERM")); + // No EXIT line => the child died mid-flight (crash/OOM/signal). + if (exitCode === null) return reject(subprocessKilled(scriptName, "UNKNOWN")); + if (exitCode !== 0) return reject(subprocessFailed(scriptName, exitCode)); + if (!text) return reject(emptyResult(scriptName)); + try { + resolve(JSON.parse(text)); + } catch { + reject(malformedResult(scriptName)); + } + }); + }); + }); +} diff --git a/platform/src/bridge.ts b/platform/src/bridge.ts index edceebec..011dbeff 100644 --- a/platform/src/bridge.ts +++ b/platform/src/bridge.ts @@ -1,7 +1,6 @@ import readline from "node:readline"; -import net from "node:net"; import path from "node:path"; -import { spawn, type ChildProcess } from "node:child_process"; +import { spawn } from "node:child_process"; import { chmod, rm } from "node:fs/promises"; import { getInternalToken } from "./auth/internal-token"; import { @@ -13,204 +12,12 @@ import { subprocessSpawnFailed, } from "./util/errors"; import pkg from "../../package.json"; +import { USE_FORK_SERVER, runForked } from "./bridge.fork"; // A line a service logged on purpose, as opposed to whatever else lands on a // stream. Only these are forwarded to the caller. const LOG_LINE = /^(INFO|DEBUG|ERROR|WARNING):/; -const USE_FORK_SERVER = ["1", "true", "yes"].includes( - (process.env.APOLLO_FORK_SERVER ?? "").toLowerCase() -); - -interface ForkMaster { - proc: ChildProcess; - socketPath: string; -} -let forkMaster: Promise | null = null; -let forkMasterProc: ChildProcess | null = null; - -function startForkMaster(): Promise { - const socketPath = path.resolve(`tmp/fork_server-${process.pid}.sock`); - const proc = spawn( - "poetry", - ["run", "python", "services/fork_server.py", "--socket", socketPath], - { - env: { - ...process.env, - APOLLO_INTERNAL_TOKEN: getInternalToken(), - APOLLO_VERSION: pkg.version, - OBJC_DISABLE_INITIALIZE_FORK_SAFETY: "YES", - }, - } - ); - forkMasterProc = proc; - - return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(subprocessSpawnFailed("fork_server", new Error("not READY in time"))), - 60_000 - ); - readline.createInterface({ input: proc.stdout!, crlfDelay: Infinity }).on("line", (line) => { - console.log(`fork-server: ${line}`); - if (line.startsWith("READY")) { - clearTimeout(timeout); - resolve({ proc, socketPath }); - } - }); - readline - .createInterface({ input: proc.stderr!, crlfDelay: Infinity }) - .on("line", (line) => console.error(`fork-server: ${line}`)); - - proc.on("error", (err) => { - clearTimeout(timeout); - reject(subprocessSpawnFailed("fork_server", err)); - }); - proc.on("exit", (code, sig) => { - clearTimeout(timeout); - console.error(`fork-server exited (code=${code} signal=${sig})`); - forkMaster = null; // next request starts a fresh master - forkMasterProc = null; - rm(socketPath).catch(() => {}); - }); - }); -} - -function getForkMaster(): Promise { - if (!forkMaster) { - forkMaster = startForkMaster().catch((err) => { - forkMaster = null; - throw err; - }); - } - return forkMaster; -} - -process.once("exit", () => forkMasterProc?.kill("SIGTERM")); - -function runForked( - scriptName: string, - port: number, - inputPath: string, - outputPath: string, - onLog?: (str: string) => void, - onEvent?: (type: string, payload: any) => void, - signal?: AbortSignal -): Promise { - return getForkMaster().then( - (master) => - new Promise((resolve, reject) => { - let childPid: number | null = null; - let exitCode: number | null = null; - let cancelled = false; - let hardKill: ReturnType | undefined; - - const cleanupFiles = async () => { - try { - await rm(inputPath); - await rm(outputPath); - } catch (e) { - console.error("Error removing temporary files"); - console.error(e); - } - }; - - const killChild = () => { - if (childPid == null) return; // killed once the PID: line arrives - try { - process.kill(childPid, "SIGTERM"); - } catch {} - hardKill = setTimeout(() => { - try { - process.kill(childPid!, "SIGKILL"); - } catch {} - }, 5_000); - hardKill.unref?.(); - }; - - const onAbort = () => { - cancelled = true; - console.warn(`cancelling ${scriptName}: client went away`); - killChild(); - conn.destroy(); - }; - - const conn = net.createConnection({ path: master.socketPath }); - conn.on("connect", () => { - conn.write( - JSON.stringify({ service: scriptName, input: inputPath, output: outputPath, port }) + "\n" - ); - }); - - // The child's stream: the service's own stdout, wrapped in PID:/EXIT:. - const rl = readline.createInterface({ input: conn, crlfDelay: Infinity }); - rl.on("line", (line) => { - if (line.startsWith("PID:")) { - childPid = Number(line.slice(4)) || null; - if (cancelled) killChild(); - } else if (line.startsWith("EXIT:")) { - exitCode = Number(line.slice(5)); - } else if (LOG_LINE.test(line)) { - console.log(line); - onLog?.(line); - } else if (/^(EVENT)\:/.test(line)) { - const [_prefix, type, ...payload] = line.split(":"); - let processedPayload: any = payload.join(":"); - try { - processedPayload = JSON.parse(processedPayload); - } catch (e) { - // No json, no problem - } - onEvent?.(type, processedPayload); - } - }); - - if (signal) { - if (signal.aborted) onAbort(); - else signal.addEventListener("abort", onAbort, { once: true }); - } - - conn.on("error", async (err) => { - if (hardKill) clearTimeout(hardKill); - signal?.removeEventListener("abort", onAbort); - await cleanupFiles(); - reject(subprocessSpawnFailed(scriptName, err)); - }); - - conn.on("close", async () => { - rl.close(); - if (hardKill) clearTimeout(hardKill); - signal?.removeEventListener("abort", onAbort); - - const text = await Bun.file(outputPath) - .text() - .catch(() => ""); - await cleanupFiles(); - - if (cancelled) return reject(subprocessCancelled(scriptName, "SIGTERM")); - if (exitCode && exitCode !== 0) return reject(subprocessFailed(scriptName, exitCode)); - // No EXIT line => the child died mid-flight (crash/OOM/signal). - if (exitCode === null) return reject(subprocessKilled(scriptName, "UNKNOWN")); - if (text) { - try { - return resolve(JSON.parse(text)); - } catch (e) { - console.error(`Unreadable output from ${scriptName}`); - console.error(e); - return reject(malformedResult(scriptName)); - } - } - console.warn("No data returned from pythonland"); - return reject(emptyResult(scriptName)); - }); - }), - async (err) => { - await rm(inputPath).catch(() => {}); - await rm(outputPath).catch(() => {}); - throw err; - } - ); -} - /** Run a python script Each script will be run in its own thread because diff --git a/services/entry.fork.py b/services/entry.fork.py new file mode 100644 index 00000000..1211c783 --- /dev/null +++ b/services/entry.fork.py @@ -0,0 +1,129 @@ +"""Fork entry for the Python side (used when APOLLO_FORK_SERVER=true; see +platform/src/bridge.fork.ts). Run as a script, never imported. + +Pays the heavy imports once, then forks a fresh child per request. Each child +inherits the imports via copy-on-write and imports entry.py AFTER forking, so +entry.py's own init runs in the child where it's fork-safe - entry.py is left +untouched. The parent only imports and never inits, so it stays forkable. + +macOS: set OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES in your env (see .env.example), +or a forked child that touches an Objective-C framework aborts. No-op on Linux. + +Protocol (NDJSON over a unix socket, one connection per request): + bridge -> master : {"service","input","output","port"} + child -> bridge : PID: then the service's stdout then EXIT: +""" + +import argparse +import importlib +import json +import os +import signal +import socket +import sys +import traceback + +# Warm entry.py's heavy deps so forked children inherit them (copy-on-write). +# Mirrors entry.py's imports; a missing one is just imported per-child - slower, +# never wrong. +PRELOAD = ( + "sentry_sdk", + "anthropic", + "langfuse", + "langfuse_util", + "util", + "opentelemetry.instrumentation.anthropic", + "opentelemetry.instrumentation.threading", +) + + +def preload() -> int: + """Warm the shared deps, plus any opt-in services. Services are opt-in + (APOLLO_FORK_PRELOAD_SERVICES) because some do I/O at import and would hang + the parent; the child imports the rest on demand.""" + for module in PRELOAD: + importlib.import_module(module) + + names = [s.strip() for s in os.environ.get("APOLLO_FORK_PRELOAD_SERVICES", "").split(",") if s.strip()] + loaded = 0 + for name in names: + try: + importlib.import_module(f"{name}.{name}") + loaded += 1 + except Exception as e: + print(f"entry.fork: could not preload {name}: {e}", file=sys.stderr, flush=True) + return loaded + + +def run_child(conn: socket.socket, req: dict) -> None: + """In the forked child: stream through the socket, run one job, exit.""" + # Route stdout/stderr to the socket so the service's logging reaches the bridge. + os.dup2(conn.fileno(), 1) + os.dup2(conn.fileno(), 2) + sys.stdout = os.fdopen(1, "w", buffering=1) + sys.stderr = os.fdopen(2, "w", buffering=1) + + print(f"PID:{os.getpid()}") # first, so the bridge can cancel this child + + code = 0 + try: + import entry # post-fork: entry.py's init runs here (fork-safe), deps warm + + entry.call( + service=req["service"], + input_path=req.get("input"), + output_path=req.get("output"), + apollo_port=req.get("port"), + ) + except Exception: + traceback.print_exc() + code = 1 + finally: + print(f"EXIT:{code}") + os._exit(code) + + +def read_request(conn: socket.socket) -> dict | None: + """Read one newline-terminated JSON request; None if there isn't a usable one.""" + line = b"" + while not line.endswith(b"\n"): + chunk = conn.recv(65536) + if not chunk: + break + line += chunk + try: + return json.loads(line) if line else None + except json.JSONDecodeError: + return None + + +def serve(sock_path: str) -> None: + signal.signal(signal.SIGCHLD, signal.SIG_IGN) # auto-reap children + count = preload() + + if os.path.exists(sock_path): + os.unlink(sock_path) + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(sock_path) + server.listen(128) + print(f"READY services={count}", flush=True) # handshake the bridge waits for + + while True: + try: + conn, _ = server.accept() + except InterruptedError: + continue + req = read_request(conn) + if req is None: + conn.close() + continue + if os.fork() == 0: + server.close() # the child must not hold the listening socket + run_child(conn, req) # never returns + conn.close() # parent drops its copy; the child owns the reply + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--socket", required=True) + serve(parser.parse_args().socket) From 03a108147bf2998e2dbb6e7cbc6c1d52d76ee9eb Mon Sep 17 00:00:00 2001 From: doc-han Date: Wed, 7 Oct 2026 11:35:47 +0000 Subject: [PATCH 11/26] chore: update changelog --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index a3f087b1..5e945f07 100644 --- a/.env.example +++ b/.env.example @@ -70,9 +70,9 @@ LANGFUSE_BASE_URL=https://cloud.langfuse.com # ZILLIZ_URI = https://in01-XXXXXXXXXXXXX.aws-us-west-2.vectordb.zillizcloud.com:XXXXX # ZILLIZ_TOKEN =db_admin:password (or ApiKey) -# Warm fork-server (see services/entry.fork.py). Flip to "true" to use it. +# Turn this on to use the fork server mechanism # APOLLO_FORK_SERVER=true -# macOS only: required when APOLLO_FORK_SERVER=true, or a forked child that touches -# an Objective-C framework aborts. No-op on Linux, safe to leave set everywhere. + +# macOS only: required when APOLLO_FORK_SERVER=true # OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES From d154fa2475a9522eb1c12313c7c975627e8a8b52 Mon Sep 17 00:00:00 2001 From: IZO-Ong Date: Tue, 29 Sep 2026 11:18:15 +0800 Subject: [PATCH 12/26] feat: cache planner history across tool-loop rounds --- services/global_chat/planner.py | 6 ++ .../global_chat/tests/unit/test_planner.py | 98 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/services/global_chat/planner.py b/services/global_chat/planner.py index aa1fd95e..971404e5 100644 --- a/services/global_chat/planner.py +++ b/services/global_chat/planner.py @@ -53,6 +53,10 @@ }, } +# API puts a breakpoint on the last block of each request, +# so each tool-loop round reads the rounds before it from cache. +_HISTORY_CACHE = {"cache_control": {"type": "ephemeral"}} + _FINAL_ROUND_NOTICE = ( "Stop and reply to the user now. Say what you changed. Mention unfinished work " "only if there is any, and then offer to continue next turn — otherwise don't " @@ -614,6 +618,7 @@ def _call_api(self, system_prompt, messages, stream, stream_manager, tool_choice output_config={"effort": "medium"}, **choice, **_CONTEXT_MANAGEMENT, + **_HISTORY_CACHE, ) as stream_obj: for event in stream_obj: if event.type == "content_block_delta" and event.delta.type == "text_delta": @@ -644,6 +649,7 @@ def _call_api(self, system_prompt, messages, stream, stream_manager, tool_choice # which the SDK otherwise rejects. timeout=httpx.Timeout(600.0, connect=5.0), **_CONTEXT_MANAGEMENT, + **_HISTORY_CACHE, ) return response diff --git a/services/global_chat/tests/unit/test_planner.py b/services/global_chat/tests/unit/test_planner.py index 70fb8766..9a9f8fb1 100644 --- a/services/global_chat/tests/unit/test_planner.py +++ b/services/global_chat/tests/unit/test_planner.py @@ -1592,3 +1592,101 @@ def always_fail(*_args: object, **_kwargs: object) -> FakeResponse: assert "something else" not in excinfo.value.details["upstream_message"] # The retry failed, so nothing was claimed about web search. assert planner._segments == [] + + +class RecordingMessages: + """Records the kwargs of every stream/create call and replays scripted outcomes. + + Each outcome is a FakeResponse to return, or an exception to raise. + """ + + def __init__(self, outcomes: list) -> None: + self._outcomes = list(outcomes) + self.calls: list[dict] = [] + + def _next(self, kwargs: dict) -> FakeResponse: + self.calls.append(kwargs) + outcome = self._outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + def create(self, **kwargs: object) -> FakeResponse: + return self._next(kwargs) + + def stream(self, **kwargs: object) -> FakeStream: + return FakeStream([], self._next(kwargs)) + + +class RecordingClient: + def __init__(self, outcomes: list) -> None: + self.messages = RecordingMessages(outcomes) + self.beta = self + + +HISTORY_CACHE = {"type": "ephemeral"} + + +def test_a_streamed_call_sends_history_cache() -> None: + planner = make_run_planner() + planner.client = RecordingClient([FakeResponse("end_turn", [FakeTextBlock("Hi")])]) + + planner._call_api([], [], True, StubStreamManager()) + + assert planner.client.messages.calls[0]["cache_control"] == HISTORY_CACHE + + +def test_a_non_streamed_call_sends_history_cache() -> None: + planner = make_run_planner() + planner.client = RecordingClient([FakeResponse("end_turn", [FakeTextBlock("Hi")])]) + + planner._call_api([], [], False, StubStreamManager()) + + assert planner.client.messages.calls[0]["cache_control"] == HISTORY_CACHE + + +def test_every_round_of_a_turn_carries_history_cache() -> None: + """Pause continuation, tool round and the no-tools wrap-up all re-send history.""" + planner = make_run_planner(max_tool_calls=1) + planner.tools = TOOL_DEFINITIONS + planner.config_loader = StubPromptLoader(planner_system_prompt="BASE PROMPT") + inspect = FakeToolUse("inspect_job_code", {"job_keys": ["fetch-patients"]}) + planner.client = RecordingClient([ + FakeResponse("pause_turn", [FakeTextBlock("Looking. ")]), + FakeResponse("tool_use", [inspect]), + FakeResponse("end_turn", [FakeTextBlock("Done.")]), + ]) + + result = planner.run("q", WORKFLOW_YAML, None, [], stream=False) + + calls = planner.client.messages.calls + assert result.response == "Done." + assert len(calls) == 3 + # The last call is the budget-spent wrap-up round. + assert calls[-1]["tool_choice"] == {"type": "none"} + for call in calls: + assert call["cache_control"] == HISTORY_CACHE + # The existing explicit breakpoints are unchanged: one on the tools, + # one on the system prompt, plus the automatic one is 3 of the API's 4. + explicit = [t for t in call["tools"] if "cache_control" in t] + \ + [b for b in call["system"] if "cache_control" in b] + assert len(explicit) == 2 + + +def test_web_downgrade_retry_keeps_history_cache() -> None: + planner = make_run_planner() + planner.web_tools = build_web_tools(WEB_CONFIG) + planner.tools = TOOL_DEFINITIONS + planner.web_tools + planner.web_search_enabled = True + planner.config_loader = StubPromptLoader(planner_system_prompt="BASE PROMPT") + planner.client = RecordingClient([ + make_bad_request(), + FakeResponse("end_turn", [FakeTextBlock("Answered without the web.")]), + ]) + + result = planner.run("q", None, None, [], stream=False) + + calls = planner.client.messages.calls + assert result.meta["web_search_downgraded"] is True + assert len(calls) == 2 + assert all(call["cache_control"] == HISTORY_CACHE for call in calls) From 299580e338ad37925966818e7f0425d54136ace2 Mon Sep 17 00:00:00 2001 From: IZO-Ong Date: Tue, 29 Sep 2026 12:06:42 +0800 Subject: [PATCH 13/26] docs: add changeset --- .changeset/warm-pages-cache.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/warm-pages-cache.md diff --git a/.changeset/warm-pages-cache.md b/.changeset/warm-pages-cache.md new file mode 100644 index 00000000..76639dc0 --- /dev/null +++ b/.changeset/warm-pages-cache.md @@ -0,0 +1,5 @@ +--- +"apollo": patch +--- + +global_chat: cache the planner's history across tool-loop rounds From 1a1f9755463aaf0bd6a7771b45957bf55c3575a9 Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Thu, 8 Oct 2026 21:19:52 +0100 Subject: [PATCH 14/26] Use httpx2 to match anthropic 1.x --- services/global_chat/planner.py | 12 ++-- .../global_chat/tests/unit/test_planner.py | 66 +++++++++---------- services/job_chat/job_chat.py | 4 +- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/services/global_chat/planner.py b/services/global_chat/planner.py index 42ba790a..1735c5c7 100644 --- a/services/global_chat/planner.py +++ b/services/global_chat/planner.py @@ -6,7 +6,7 @@ from typing import List, Dict, Optional from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass -import httpx +import httpx2 import anthropic from anthropic import Anthropic import sentry_sdk @@ -74,7 +74,7 @@ def _api_error_message(error: Exception) -> str: if isinstance(message, str) and message: return message - if isinstance(error, (anthropic.APIConnectionError, httpx.RequestError)): + if isinstance(error, (anthropic.APIConnectionError, httpx2.RequestError)): return str(error) or type(error).__name__ status = getattr(error, "status_code", None) @@ -158,7 +158,7 @@ def _model_api_failure(error: Exception) -> tuple[int, str, str, dict]: # leaves the server and nothing upstream is trusted to be key-free. details = {"upstream_message": mask_secrets(upstream)} - if isinstance(error, (anthropic.APIConnectionError, httpx.RequestError)): + if isinstance(error, (anthropic.APIConnectionError, httpx2.RequestError)): cause = getattr(error, "__cause__", None) if cause: # A proxy error carries the proxy URL, credentials and all. @@ -387,10 +387,10 @@ def run( except ApolloError: raise - except (anthropic.APIError, httpx.RequestError) as e: + except (anthropic.APIError, httpx2.RequestError) as e: # A rejection from the model API is not a tool failure. # - # httpx.RequestError as well as APIError: the SDK only + # httpx2.RequestError as well as APIError: the SDK only # wraps httpx exceptions around the initial send, and on a # streaming request that returns once the headers arrive. # The body is read later, unwrapped, so a stream that dies @@ -569,7 +569,7 @@ def _call_api(self, system_prompt, messages, stream, stream_manager, tool_choice # Per-request timeout (same values as the SDK default): # required for non-streaming calls with max_tokens > ~21k, # which the SDK otherwise rejects. - timeout=httpx.Timeout(600.0, connect=5.0), + timeout=httpx2.Timeout(600.0, connect=5.0), **_CONTEXT_MANAGEMENT, ) return response diff --git a/services/global_chat/tests/unit/test_planner.py b/services/global_chat/tests/unit/test_planner.py index 81fa0567..f66fb9ec 100644 --- a/services/global_chat/tests/unit/test_planner.py +++ b/services/global_chat/tests/unit/test_planner.py @@ -3,7 +3,7 @@ from unittest.mock import patch import anthropic -import httpx +import httpx2 import pytest from util import ApolloError @@ -412,8 +412,8 @@ def test_model_api_error_is_not_reported_as_a_tool_failure() -> None: # Shaped like a real rejection: the SDK's own str() is the dict repr, # and the readable sentence is in the parsed body. - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") - response = httpx.Response(400, request=request, json={}) + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") + response = httpx2.Response(400, request=request, json={}) api_error = anthropic.BadRequestError( "Error code: 400 - {'type': 'error', 'error': {...}}", response=response, @@ -470,11 +470,11 @@ def _raise_from_planner(error: Exception) -> ApolloError: def test_a_rejected_key_does_not_describe_our_account_to_the_caller() -> None: """The upstream sentence for an auth failure is about our key, and every user of the instance would be shown it in chat.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") error = _raise_from_planner( anthropic.AuthenticationError( "Error code: 401 - {'type': 'error', 'error': {...}}", - response=httpx.Response(401, request=request, json={}), + response=httpx2.Response(401, request=request, json={}), body={ "type": "error", "error": { @@ -497,11 +497,11 @@ def test_our_own_request_shape_being_wrong_is_not_blamed_on_the_caller() -> None """The planner sends beta headers, thinking and context-management config and tool definitions the caller never touches. Anthropic rejects those with a 400, and answering the caller 400 would blame them for it.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") error = _raise_from_planner( anthropic.BadRequestError( "Error code: 400", - response=httpx.Response(400, request=request, json={}), + response=httpx2.Response(400, request=request, json={}), body={ "type": "error", "error": { @@ -527,11 +527,11 @@ def test_our_own_request_shape_being_wrong_is_not_blamed_on_the_caller() -> None def test_a_payload_over_the_size_limit_is_the_callers_to_act_on() -> None: """413 is the sibling of prompt-too-long: their workflow is too big, and a retry of the same payload can never succeed.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") error = _raise_from_planner( anthropic.RequestTooLargeError( "Error code: 413", - response=httpx.Response(413, request=request, json={}), + response=httpx2.Response(413, request=request, json={}), body={"type": "error", "error": {"type": "request_too_large", "message": "request body too large"}}, ), @@ -548,11 +548,11 @@ def test_a_payload_over_the_size_limit_is_the_callers_to_act_on() -> None: def test_a_stream_that_dies_mid_reply_does_not_record_the_opening_200() -> None: """The SDK builds a mid-stream error from the response that opened the stream, which succeeded, so its status would read as a call that worked.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") error = _raise_from_planner( anthropic.APIStatusError( "Overloaded", - response=httpx.Response(200, request=request, json={}), + response=httpx2.Response(200, request=request, json={}), body={"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}, ), @@ -567,13 +567,13 @@ def test_a_mid_stream_failure_is_classified_by_what_the_body_says() -> None: opened the stream, whatever actually went wrong, so the class says nothing and the body's type is the only thing left. Global chat streams, so this is the common path, not the corner.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") def mid_stream(kind: str, message: str) -> ApolloError: return _raise_from_planner( anthropic.APIStatusError( f"{{'type': 'error', 'error': {{'type': '{kind}'}}}}", - response=httpx.Response(200, request=request, json={}), + response=httpx2.Response(200, request=request, json={}), body={"type": "error", "error": {"type": kind, "message": message}}, ), @@ -609,11 +609,11 @@ def test_an_overload_with_no_readable_body_still_says_to_try_again() -> None: """A 529 is the response most likely to come back from an edge with an HTML body, and then there is no type to read. It is also the one kind whose advice differs, so falling back to the class matters here.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") error = _raise_from_planner( anthropic.OverloadedError( "Error code: 529", - response=httpx.Response(529, request=request, text="busy"), + response=httpx2.Response(529, request=request, text="busy"), body="busy", ), ) @@ -625,11 +625,11 @@ def test_an_overload_with_no_readable_body_still_says_to_try_again() -> None: def test_details_do_not_carry_key_shaped_values_off_the_server() -> None: """details is serialised to the caller (errors.ts toErrorPayload), so the upstream sentence leaves the server even though only message is rendered.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") error = _raise_from_planner( anthropic.BadRequestError( "Error code: 400", - response=httpx.Response(400, request=request, json={}), + response=httpx2.Response(400, request=request, json={}), body={ "type": "error", "error": { @@ -646,8 +646,8 @@ def test_details_do_not_carry_key_shaped_values_off_the_server() -> None: def test_the_cause_of_a_connection_failure_is_masked_too() -> None: """A proxy error carries the proxy URL, which is where credentials get written, and details is serialised to the caller.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") - dropped = httpx.ProxyError("proxy rejected") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") + dropped = httpx2.ProxyError("proxy rejected") dropped.__cause__ = Exception("http://user:sk-ant-api03-" + "b" * 95 + "@proxy:8080") error = _raise_from_planner(dropped) @@ -658,9 +658,9 @@ def test_the_cause_of_a_connection_failure_is_masked_too() -> None: def test_a_dropped_connection_keeps_the_text_httpx_wrote() -> None: """The SDK only wraps httpx around the initial send, so a stream that dies while the body is being read raises a raw httpx error with no status.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") error = _raise_from_planner( - httpx.ReadError("peer closed connection", request=request), + httpx2.ReadError("peer closed connection", request=request), ) assert error.code == 503 @@ -722,11 +722,11 @@ class FakeError(Exception): def test_a_rate_limit_keeps_the_retry_after_the_bridge_contract_pins() -> None: """platform/test/server.test.ts asserts RATE_LIMIT carries retry_after, and dropping it would leave a caller with nothing to wait on.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") error = _raise_from_planner( anthropic.RateLimitError( "Error code: 429", - response=httpx.Response( + response=httpx2.Response( 429, request=request, json={}, headers={"retry-after": "30"}, ), body={"type": "error", "error": {"type": "rate_limit_error", @@ -740,11 +740,11 @@ def test_a_rate_limit_keeps_the_retry_after_the_bridge_contract_pins() -> None: def test_a_rate_limit_without_the_header_still_gives_something_to_wait_on() -> None: - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") error = _raise_from_planner( anthropic.RateLimitError( "Error code: 429", - response=httpx.Response(429, request=request, json={}), + response=httpx2.Response(429, request=request, json={}), body={}, ), ) @@ -757,13 +757,13 @@ def test_a_broken_retry_after_header_does_not_raise_inside_the_handler() -> None header would raise from inside the except block and arrive as the generic 500 this whole change exists to remove. And the value is someone else's, so a caller told to wait a century would wait forever.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") def rate_limited(header: bytes) -> ApolloError: return _raise_from_planner( anthropic.RateLimitError( "Error code: 429", - response=httpx.Response( + response=httpx2.Response( 429, request=request, json={}, headers=[(b"retry-after", header)], ), @@ -788,17 +788,17 @@ def test_our_account_failing_is_not_reported_as_the_caller_failing() -> None: """A 403 from the model API means our org cannot use it. Passing it through would answer the caller with a verdict we reached about ourselves, which is what the 401 rule exists to prevent.""" - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") for sdk_error in ( anthropic.PermissionDeniedError( "Error code: 403", - response=httpx.Response(403, request=request, json={}), + response=httpx2.Response(403, request=request, json={}), body={"type": "error", "error": {"type": "permission_error", "message": "not allowed"}}, ), anthropic.NotFoundError( "Error code: 404", - response=httpx.Response(404, request=request, json={}), + response=httpx2.Response(404, request=request, json={}), body={"type": "error", "error": {"type": "not_found_error", "message": "model: nope"}}, ), @@ -815,7 +815,7 @@ def test_connection_failure_is_not_reported_as_a_tool_failure() -> None: planner.model = "test-model" planner.max_tool_calls = 5 - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") api_error = anthropic.APIConnectionError(request=request) with ( @@ -843,7 +843,7 @@ def test_stream_dropping_midway_is_not_reported_as_a_tool_failure() -> None: planner.model = "test-model" planner.max_tool_calls = 5 - dropped = httpx.RemoteProtocolError( + dropped = httpx2.RemoteProtocolError( "peer closed connection without sending complete message body" ) @@ -864,7 +864,7 @@ def test_connection_error_keeps_the_sdk_wording() -> None: """APIConnectionError's message is the SDK's own constant, not upstream text, so it is worth more to a reader than the class name.""" error = anthropic.APIConnectionError( - request=httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request=httpx2.Request("POST", "https://api.anthropic.com/v1/messages") ) assert _api_error_message(error) == "Connection error." diff --git a/services/job_chat/job_chat.py b/services/job_chat/job_chat.py index 919040ed..9cc70017 100644 --- a/services/job_chat/job_chat.py +++ b/services/job_chat/job_chat.py @@ -4,7 +4,7 @@ import yaml from typing import List, Optional, Dict, Any from dataclasses import dataclass -import httpx +import httpx2 from anthropic import ( Anthropic, APIConnectionError, @@ -370,7 +370,7 @@ def generate( # Per-request timeout (same values as the SDK default): # required for non-streaming calls with max_tokens > ~21k, # which the SDK otherwise rejects. - timeout=httpx.Timeout(600.0, connect=5.0), + timeout=httpx2.Timeout(600.0, connect=5.0), **tool_kwargs ) message = self.client.messages.create(**create_kwargs) From 2ba057a5afa3c5cfb747a8b1ac049068f0a559c5 Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Thu, 8 Oct 2026 21:19:52 +0100 Subject: [PATCH 15/26] Drop temperature, removed in anthropic 1.x --- services/global_chat/config.yaml | 1 - services/global_chat/router.py | 2 -- services/job_chat/rag.yaml | 1 - services/job_chat/retrieve_docs.py | 5 +---- services/job_chat/tests/unit/test_retrieve_docs.py | 2 -- services/vocab_mapper/vocab_mapper.py | 1 - 6 files changed, 1 insertion(+), 11 deletions(-) diff --git a/services/global_chat/config.yaml b/services/global_chat/config.yaml index 112b13e9..49be678e 100644 --- a/services/global_chat/config.yaml +++ b/services/global_chat/config.yaml @@ -4,7 +4,6 @@ router: model: "claude-haiku" max_tokens: 500 - temperature: 0.0 # Planner configuration (complex orchestration) planner: diff --git a/services/global_chat/router.py b/services/global_chat/router.py index c2431862..2fd58247 100644 --- a/services/global_chat/router.py +++ b/services/global_chat/router.py @@ -70,7 +70,6 @@ def __init__(self, config_loader: ConfigLoader, api_key: Optional[str] = None): router_config = config_loader.config.get("router", {}) self.model = resolve_model(router_config.get("model", "claude-haiku")) self.max_tokens = router_config.get("max_tokens", 500) - self.temperature = router_config.get("temperature", 0.0) logger.info(f"RouterAgent initialized with model: {self.model}") @@ -174,7 +173,6 @@ def _make_routing_decision( response = self.client.messages.create( model=self.model, max_tokens=self.max_tokens, - temperature=self.temperature, system=[{"type": "text", "text": system_prompt}], messages=[ {"role": "user", "content": routing_message} diff --git a/services/job_chat/rag.yaml b/services/job_chat/rag.yaml index 13caa0de..d6bdd320 100644 --- a/services/job_chat/rag.yaml +++ b/services/job_chat/rag.yaml @@ -4,5 +4,4 @@ llm_search_decision: "claude-sonnet" llm_retrieval: "claude-sonnet" threshold: 0.8 top_k: 5 -temperature: 0 prompts_version: 1.0 diff --git a/services/job_chat/retrieve_docs.py b/services/job_chat/retrieve_docs.py index 33d35a89..9e80cf95 100644 --- a/services/job_chat/retrieve_docs.py +++ b/services/job_chat/retrieve_docs.py @@ -113,7 +113,6 @@ def needs_docs(content, client, user_context=""): response_text, usage = call_llm( model=resolve_model(config["llm_search_decision"]), - temperature=config["temperature"], system_prompt=config_loader.prompts["prompts"]["needs_docs_system_prompt"], user_prompt=formatted_user_prompt, client=client @@ -148,7 +147,6 @@ def generate_queries(content, client, user_context=""): text, usage = call_llm( model=resolve_model(config["llm_retrieval"]), - temperature=config["temperature"], system_prompt=config_loader.prompts["prompts"]["search_docs_system_prompt"], user_prompt=formatted_user_prompt, client=client, @@ -208,7 +206,7 @@ def format_context(adaptor, code, history): return formatted_text -def call_llm(model, temperature, system_prompt, user_prompt, client, output_schema=None): +def call_llm(model, system_prompt, user_prompt, client, output_schema=None): """Helper method to make LLM calls with error handling.""" try: messages = [ @@ -226,7 +224,6 @@ def call_llm(model, temperature, system_prompt, user_prompt, client, output_sche kwargs = dict( model=model, max_tokens=1024, - temperature=temperature, system=system_prompt, messages=messages ) diff --git a/services/job_chat/tests/unit/test_retrieve_docs.py b/services/job_chat/tests/unit/test_retrieve_docs.py index 45b57ddf..58b60b03 100644 --- a/services/job_chat/tests/unit/test_retrieve_docs.py +++ b/services/job_chat/tests/unit/test_retrieve_docs.py @@ -51,7 +51,6 @@ def test_call_llm_returns_text_and_usage_on_success(): text, usage = rd.call_llm( model="claude-haiku-4-5", - temperature=0, system_prompt="sys", user_prompt="usr", client=client, @@ -68,7 +67,6 @@ def test_call_llm_wraps_unexpected_error_as_apollo_error(): with pytest.raises(ApolloError) as exc: rd.call_llm( model="claude-haiku-4-5", - temperature=0, system_prompt="sys", user_prompt="usr", client=client, diff --git a/services/vocab_mapper/vocab_mapper.py b/services/vocab_mapper/vocab_mapper.py index 29189206..8310f1d9 100644 --- a/services/vocab_mapper/vocab_mapper.py +++ b/services/vocab_mapper/vocab_mapper.py @@ -39,7 +39,6 @@ def _call_llm(self, system_prompt: str, user_prompt: str) -> str: message = self.client.messages.create( model=CLAUDE_SONNET, max_tokens=500, - temperature=0, system=system_prompt, messages=[ { From fb82a745d4078486f1f296021da972e1cc35300e Mon Sep 17 00:00:00 2001 From: doc-han Date: Fri, 9 Oct 2026 08:47:47 +0000 Subject: [PATCH 16/26] fix: resolve issues --- platform/src/bridge.fork.ts | 2 +- services/entry.fork.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/platform/src/bridge.fork.ts b/platform/src/bridge.fork.ts index c3c0e7ea..70177b79 100644 --- a/platform/src/bridge.fork.ts +++ b/platform/src/bridge.fork.ts @@ -66,10 +66,10 @@ function startMaster(): Promise { }); proc.on("error", fail); proc.on("exit", (code, sig) => { - clearTimeout(timer); console.error(`fork-server exited (code=${code} signal=${sig})`); master = masterProc = null; // next request starts a fresh one rm(socketPath).catch(() => {}); + fail(new Error(`fork-server exited before ready (code=${code} signal=${sig})`)); }); }); } diff --git a/services/entry.fork.py b/services/entry.fork.py index 1211c783..9c4fef70 100644 --- a/services/entry.fork.py +++ b/services/entry.fork.py @@ -23,6 +23,8 @@ import sys import traceback +import sentry_sdk # for the per-child service tag + flush (see run_child) + # Warm entry.py's heavy deps so forked children inherit them (copy-on-write). # Mirrors entry.py's imports; a missing one is just imported per-child - slower, # never wrong. @@ -57,11 +59,11 @@ def preload() -> int: def run_child(conn: socket.socket, req: dict) -> None: """In the forked child: stream through the socket, run one job, exit.""" - # Route stdout/stderr to the socket so the service's logging reaches the bridge. + # Route stdout to the socket so the service's logging reaches the bridge. + # Leave stderr on fd 2 (the master's stderr pipe) so Python tracebacks still + # reach the server logs, the way the spawn path forwards them. os.dup2(conn.fileno(), 1) - os.dup2(conn.fileno(), 2) sys.stdout = os.fdopen(1, "w", buffering=1) - sys.stderr = os.fdopen(2, "w", buffering=1) print(f"PID:{os.getpid()}") # first, so the bridge can cancel this child @@ -69,6 +71,7 @@ def run_child(conn: socket.socket, req: dict) -> None: try: import entry # post-fork: entry.py's init runs here (fork-safe), deps warm + sentry_sdk.set_tag("service", req["service"]) # entry.main() tags it; call() doesn't entry.call( service=req["service"], input_path=req.get("input"), @@ -79,6 +82,16 @@ def run_child(conn: socket.socket, req: dict) -> None: traceback.print_exc() code = 1 finally: + try: + sentry_sdk.flush(timeout=2) + except Exception: + pass + langfuse = getattr(sys.modules.get("entry"), "langfuse", None) + if langfuse is not None: + try: + langfuse.flush() + except Exception: + pass print(f"EXIT:{code}") os._exit(code) From e7dab29c37503066f666d8aeebabd0e40de1ba83 Mon Sep 17 00:00:00 2001 From: doc-han Date: Mon, 12 Oct 2026 09:24:40 +0000 Subject: [PATCH 17/26] feat: move imports to yaml --- services/entry.fork.py | 19 +++++++------------ services/fork_preload.yaml | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 services/fork_preload.yaml diff --git a/services/entry.fork.py b/services/entry.fork.py index 9c4fef70..c83cbf71 100644 --- a/services/entry.fork.py +++ b/services/entry.fork.py @@ -22,28 +22,23 @@ import socket import sys import traceback +from pathlib import Path import sentry_sdk # for the per-child service tag + flush (see run_child) +import yaml # Warm entry.py's heavy deps so forked children inherit them (copy-on-write). -# Mirrors entry.py's imports; a missing one is just imported per-child - slower, -# never wrong. -PRELOAD = ( - "sentry_sdk", - "anthropic", - "langfuse", - "langfuse_util", - "util", - "opentelemetry.instrumentation.anthropic", - "opentelemetry.instrumentation.threading", -) +# See fork_preload.yaml for the list and why each module is there. +PRELOAD_PATH = Path(__file__).parent / "fork_preload.yaml" def preload() -> int: """Warm the shared deps, plus any opt-in services. Services are opt-in (APOLLO_FORK_PRELOAD_SERVICES) because some do I/O at import and would hang the parent; the child imports the rest on demand.""" - for module in PRELOAD: + with PRELOAD_PATH.open() as f: + modules = yaml.safe_load(f)["preload"] + for module in modules: importlib.import_module(module) names = [s.strip() for s in os.environ.get("APOLLO_FORK_PRELOAD_SERVICES", "").split(",") if s.strip()] diff --git a/services/fork_preload.yaml b/services/fork_preload.yaml new file mode 100644 index 00000000..7aac853a --- /dev/null +++ b/services/fork_preload.yaml @@ -0,0 +1,22 @@ +# Modules the fork master (entry.fork.py) imports once at startup, before +# accepting any requests. A forked child inherits these via copy-on-write, so +# the cost of importing them is paid once per server lifetime instead of once +# per request. A module missing from this list isn't an error - the child +# just imports it itself on demand, which is slower but never wrong. + +preload: + # Mirrors entry.py's own top-level imports (Sentry init, Langfuse, otel). + - sentry_sdk + - anthropic + - langfuse + - langfuse_util + - util + - opentelemetry.instrumentation.anthropic + - opentelemetry.instrumentation.threading + + # global_chat is ~95% of fork traffic; warm its RAG path (search_docsite) + # so the common case doesn't pay for these on the first request. + - pinecone + - openai + - langchain_openai + - langchain_pinecone From cb34f293606ad935ffed5d5a77fe6d7545d50f4e Mon Sep 17 00:00:00 2001 From: doc-han Date: Mon, 12 Oct 2026 09:26:25 +0000 Subject: [PATCH 18/26] chore: share log lines --- platform/src/bridge.fork.ts | 4 +--- platform/src/bridge.ts | 5 +---- platform/src/util/log-line.ts | 4 ++++ 3 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 platform/src/util/log-line.ts diff --git a/platform/src/bridge.fork.ts b/platform/src/bridge.fork.ts index 70177b79..a6d34b14 100644 --- a/platform/src/bridge.fork.ts +++ b/platform/src/bridge.fork.ts @@ -12,11 +12,9 @@ import { subprocessKilled, subprocessSpawnFailed, } from "./util/errors"; +import { LOG_LINE } from "./util/log-line"; import pkg from "../../package.json"; -// Only these lines are forwarded to the caller (mirrors bridge.ts). -const LOG_LINE = /^(INFO|DEBUG|ERROR|WARNING):/; - export const USE_FORK_SERVER = (process.env.APOLLO_FORK_SERVER ?? "").toLowerCase() === "true"; diff --git a/platform/src/bridge.ts b/platform/src/bridge.ts index 011dbeff..bd79ad6e 100644 --- a/platform/src/bridge.ts +++ b/platform/src/bridge.ts @@ -13,10 +13,7 @@ import { } from "./util/errors"; import pkg from "../../package.json"; import { USE_FORK_SERVER, runForked } from "./bridge.fork"; - -// A line a service logged on purpose, as opposed to whatever else lands on a -// stream. Only these are forwarded to the caller. -const LOG_LINE = /^(INFO|DEBUG|ERROR|WARNING):/; +import { LOG_LINE } from "./util/log-line"; /** Run a python script diff --git a/platform/src/util/log-line.ts b/platform/src/util/log-line.ts new file mode 100644 index 00000000..b3c69ff7 --- /dev/null +++ b/platform/src/util/log-line.ts @@ -0,0 +1,4 @@ +// A line a service logged on purpose, as opposed to whatever else lands on a +// stream. Only these are forwarded to the caller. Shared by bridge.ts and +// bridge.fork.ts. +export const LOG_LINE = /^(INFO|DEBUG|ERROR|WARNING):/; From 3b56857dcdd33fddfd304479d36d53866fcff2c0 Mon Sep 17 00:00:00 2001 From: doc-han Date: Mon, 12 Oct 2026 10:59:14 +0000 Subject: [PATCH 19/26] chore: remove log --- platform/src/bridge.fork.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/platform/src/bridge.fork.ts b/platform/src/bridge.fork.ts index a6d34b14..6427d7be 100644 --- a/platform/src/bridge.fork.ts +++ b/platform/src/bridge.fork.ts @@ -56,7 +56,6 @@ function startMaster(): Promise { const timer = setTimeout(() => fail(new Error("not READY in time")), 60_000); onLines(proc.stdout!, (line) => { - console.log(`fork-server: ${line}`); if (line.startsWith("READY")) { clearTimeout(timer); resolve({ proc, socketPath }); From 5b356f49879091c4a92e213e97627717b7487919 Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Mon, 12 Oct 2026 18:59:15 +0100 Subject: [PATCH 20/26] Pick up the newer minor/patch bumps from #715 --- poetry.lock | 71 ++++++++++++++++++++++++++------------------------ pyproject.toml | 4 +-- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/poetry.lock b/poetry.lock index 086bf8f3..2a210221 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1093,14 +1093,14 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" [[package]] name = "langchain-core" -version = "1.6.5" +version = "1.6.10" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_core-1.6.5-py3-none-any.whl", hash = "sha256:54c7b0e9314b9084fb04405bb33dc5d32986d512d92b7b8863899cd5f6267556"}, - {file = "langchain_core-1.6.5.tar.gz", hash = "sha256:bdb059fef65473fd444558dbb18f50901657402c43ceed9dc0fdc7b76a393c6b"}, + {file = "langchain_core-1.6.10-py3-none-any.whl", hash = "sha256:14341bdd8b42d0dd9a53dbbcd8b0599ab47b0c718c7caa12e3eb5c50b32cffcb"}, + {file = "langchain_core-1.6.10.tar.gz", hash = "sha256:3ad7a64eab150c1fea9f8a748b1c076aa1a960c5cf7c28d81a841a2f2dbffad1"}, ] [package.dependencies] @@ -1117,22 +1117,25 @@ uuid-utils = ">=0.12.0,<1.0" [[package]] name = "langchain-openai" -version = "1.6.6" +version = "1.7.1" 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.6.6-py3-none-any.whl", hash = "sha256:60dcaa443ddfdf1559feaf1d25916f394c7dda809aebe22a769fc39f02705be7"}, - {file = "langchain_openai-1.6.6.tar.gz", hash = "sha256:7b3848f42825aca40165463aa37757051165eb292ba7f24658a2f58f9bb221f9"}, + {file = "langchain_openai-1.7.1-py3-none-any.whl", hash = "sha256:e65f2da39199a8e155fb6ae732e0aea208a01da2710a5da701d5e1a116195fcf"}, + {file = "langchain_openai-1.7.1.tar.gz", hash = "sha256:f9fa064c576911ff8d7c6566003bffb7eb5682c43d48407310d9de6b955f5496"}, ] [package.dependencies] certifi = ">=2024.6.2" -langchain-core = ">=1.6.4,<2.0.0" +langchain-core = ">=1.6.10,<2.0.0" openai = ">=2.45.0,<4.0.0" tiktoken = ">=0.7.0,<1.0.0" +[package.extras] +azure-identity = ["aiohttp (>=3.11.0,<4.0.0)", "azure-identity (>=1.25.0,<2.0.0)"] + [[package]] name = "langchain-pinecone" version = "0.2.13" @@ -1171,18 +1174,18 @@ typing-extensions = ">=4.13.0,<5.0.0" [[package]] name = "langchain-text-splitters" -version = "1.1.2" +version = "1.1.3" description = "LangChain text splitting utilities" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "langchain_text_splitters-1.1.2-py3-none-any.whl", hash = "sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10"}, - {file = "langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627"}, + {file = "langchain_text_splitters-1.1.3-py3-none-any.whl", hash = "sha256:50edeb318b3be6a6308dddd7d616ab82823a9ccbea59a9c14c58a7b91df60295"}, + {file = "langchain_text_splitters-1.1.3.tar.gz", hash = "sha256:929b6c76f99d611a5b1d8f5591ef302909b86a436ceb3217034d24e4757868d8"}, ] [package.dependencies] -langchain-core = ">=1.2.31,<2.0.0" +langchain-core = ">=1.4.7,<2.0.0" [[package]] name = "langfuse" @@ -2454,14 +2457,14 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "1.2.3" +version = "1.2.4" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9"}, - {file = "python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35"}, + {file = "python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc"}, + {file = "python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0"}, ] [package.extras] @@ -2713,30 +2716,30 @@ requests = ">=2.0.1,<3.0.0" [[package]] name = "ruff" -version = "0.16.9" +version = "0.16.10" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96"}, - {file = "ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67"}, - {file = "ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728"}, - {file = "ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452"}, - {file = "ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e"}, - {file = "ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7"}, - {file = "ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee"}, - {file = "ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588"}, - {file = "ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385"}, - {file = "ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b"}, - {file = "ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284"}, - {file = "ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161"}, + {file = "ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511"}, + {file = "ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d"}, + {file = "ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a"}, + {file = "ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be"}, + {file = "ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d"}, + {file = "ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a"}, + {file = "ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca"}, + {file = "ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b"}, + {file = "ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017"}, ] [[package]] @@ -3849,4 +3852,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.12" -content-hash = "4e3a89d36ecc43b33a5ea63f1f2f61b84e7d6e984e9f346bbf967ab4e77455c1" +content-hash = "2add86d883acf08254e340046c1fa784e73c1fa261879c79195d43ea8183c1e0" diff --git a/pyproject.toml b/pyproject.toml index 65546f47..9eac9a5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ requires-poetry = ">=2.3.2" [tool.poetry.dependencies] python = ">=3.11,<3.12" openai = "^3.20" -python-dotenv = "^1.2.3" +python-dotenv = "^1.2.4" anthropic = "^1.9.0" langchain-pinecone = "^0.2.13" @@ -34,7 +34,7 @@ optional = false [tool.poetry.group.dev.dependencies] pytest = "^9.1.1" -ruff = "^0.16.9" +ruff = "^0.16.10" [build-system] requires = ["poetry-core"] From 034725203abe2470d2a26168649d415d0db8dbc6 Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Tue, 13 Oct 2026 09:53:29 +0100 Subject: [PATCH 21/26] Add changeset for dependency bumps --- .changeset/october-dependency-bumps.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/october-dependency-bumps.md diff --git a/.changeset/october-dependency-bumps.md b/.changeset/october-dependency-bumps.md new file mode 100644 index 00000000..9ea649d7 --- /dev/null +++ b/.changeset/october-dependency-bumps.md @@ -0,0 +1,5 @@ +--- +"apollo": patch +--- + +Update dependencies, including the Anthropic Python SDK to 1.x, the OpenAI SDK to 3.x and pandas to 3.x From 79a81ad4e6c4bd7a06275012f775a394c59ee135 Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Tue, 13 Oct 2026 09:54:52 +0100 Subject: [PATCH 22/26] Use httpx2 in the web search planner tests --- services/global_chat/tests/unit/test_planner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/global_chat/tests/unit/test_planner.py b/services/global_chat/tests/unit/test_planner.py index 00a6379c..047e2345 100644 --- a/services/global_chat/tests/unit/test_planner.py +++ b/services/global_chat/tests/unit/test_planner.py @@ -1454,9 +1454,9 @@ def test_meta_omits_the_web_fields_when_web_search_is_off() -> None: def make_bad_request(message: str = "web search is not enabled for this account") -> BadRequestError: - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") body = {"type": "error", "error": {"type": "invalid_request_error", "message": message}} - return BadRequestError(message, response=httpx.Response(400, request=request, json=body), body=body) + return BadRequestError(message, response=httpx2.Response(400, request=request, json=body), body=body) def test_a_bad_request_with_web_tools_retries_without_them() -> None: From 8ff4d376fc3b36f6853cc6f26e742c1217d627d1 Mon Sep 17 00:00:00 2001 From: doc-han Date: Tue, 13 Oct 2026 10:18:53 +0000 Subject: [PATCH 23/26] chore: update changeset --- .changeset/spicy-vans-wait.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spicy-vans-wait.md diff --git a/.changeset/spicy-vans-wait.md b/.changeset/spicy-vans-wait.md new file mode 100644 index 00000000..20e45000 --- /dev/null +++ b/.changeset/spicy-vans-wait.md @@ -0,0 +1,5 @@ +--- +"apollo": patch +--- + +fork python processes to reduce startup time From 0c7a1283c7b66ed58cc73d2c905ea7b92f282905 Mon Sep 17 00:00:00 2001 From: hanna-paasivirta Date: Wed, 14 Oct 2026 14:06:00 +0100 Subject: [PATCH 24/26] Drop changesets already released in 3.4.0 --- .changeset/october-dependency-bumps.md | 5 ----- .changeset/spicy-vans-wait.md | 5 ----- 2 files changed, 10 deletions(-) delete mode 100644 .changeset/october-dependency-bumps.md delete mode 100644 .changeset/spicy-vans-wait.md diff --git a/.changeset/october-dependency-bumps.md b/.changeset/october-dependency-bumps.md deleted file mode 100644 index 9ea649d7..00000000 --- a/.changeset/october-dependency-bumps.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apollo": patch ---- - -Update dependencies, including the Anthropic Python SDK to 1.x, the OpenAI SDK to 3.x and pandas to 3.x diff --git a/.changeset/spicy-vans-wait.md b/.changeset/spicy-vans-wait.md deleted file mode 100644 index 20e45000..00000000 --- a/.changeset/spicy-vans-wait.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apollo": patch ---- - -fork python processes to reduce startup time From e08ba094bfec1033d271befc2e3a322880d0b0d2 Mon Sep 17 00:00:00 2001 From: Hanna Paasivirta Date: Fri, 23 Oct 2026 12:53:44 +0100 Subject: [PATCH 25/26] Add skills as slash commands (#711) * Add skills as slash commands * Tell the user which skill is running * Settle the skill status where it was announced * Let the planner load skills * Shorten skill wording * Keep skills and page prefixes in history * Draft design, qa and diagnose skills * Drop placeholder note from skills README * Let the planner load skills unprompted * Let the planner hand skills to the job agent * Let the job agent load its own skills * Settle step contracts before the qa fix pass * Report skills used and add skill acceptance tests * Share simulate-users and draft-assistant-skill skills * Stop skill preamble leaking into replies * Ask simulate-users to challenge the change * Build skill fixtures from acceptance specs * Say spec_context prints context only * Acceptance run cost controls (#719) * Confirm before large acceptance runs * add no judge flag * Suggest spec subsets in run guard * Add trailing comma * Release: 3.4.0 (#720) * Bump elysia from 1.4.27 to 1.4.29 Bumps [elysia](https://github.com/elysiajs/elysia) from 1.4.27 to 1.4.29. - [Release notes](https://github.com/elysiajs/elysia/releases) - [Changelog](https://github.com/elysiajs/elysia/blob/main/CHANGELOG.md) - [Commits](https://github.com/elysiajs/elysia/compare/1.4.27...1.4.29) --- updated-dependencies: - dependency-name: elysia dependency-version: 1.4.29 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Bump urllib3 from 2.7.0 to 2.8.0 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.7.0 to 2.8.0. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.7.0...2.8.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.8.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] * feat: forked python process * Bump the python-minor-patch group with 7 updates Bumps the python-minor-patch group with 7 updates: | Package | From | To | | --- | --- | --- | | [langchain-core](https://github.com/langchain-ai/langchain) | `1.6.4` | `1.6.5` | | [langchain-openai](https://github.com/langchain-ai/langchain) | `1.6.3` | `1.6.6` | | [sentry-sdk](https://github.com/getsentry/sentry-python) | `2.70.0` | `2.71.0` | | [langfuse](https://github.com/langfuse/langfuse) | `4.15.4` | `4.15.6` | | [opentelemetry-instrumentation-anthropic](https://github.com/traceloop/openllmetry) | `0.62.3` | `0.62.4` | | [opentelemetry-instrumentation-threading](https://github.com/open-telemetry/opentelemetry-python-contrib) | `0.65b0` | `0.66b0` | | [ruff](https://github.com/astral-sh/ruff) | `0.16.8` | `0.16.9` | Updates `langchain-core` from 1.6.4 to 1.6.5 - [Release notes](https://github.com/langchain-ai/langchain/releases) - [Commits](https://github.com/langchain-ai/langchain/compare/langchain-core==1.6.4...langchain-core==1.6.5) Updates `langchain-openai` from 1.6.3 to 1.6.6 - [Release notes](https://github.com/langchain-ai/langchain/releases) - [Commits](https://github.com/langchain-ai/langchain/compare/langchain-openai==1.6.3...langchain-openai==1.6.6) Updates `sentry-sdk` from 2.70.0 to 2.71.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.70.0...2.71.0) Updates `langfuse` from 4.15.4 to 4.15.6 - [Release notes](https://github.com/langfuse/langfuse/releases) - [Commits](https://github.com/langfuse/langfuse/commits) Updates `opentelemetry-instrumentation-anthropic` from 0.62.3 to 0.62.4 - [Release notes](https://github.com/traceloop/openllmetry/releases) - [Changelog](https://github.com/traceloop/openllmetry/blob/main/CHANGELOG.md) - [Commits](https://github.com/traceloop/openllmetry/compare/0.62.3...0.62.4) Updates `opentelemetry-instrumentation-threading` from 0.65b0 to 0.66b0 - [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.16.8 to 0.16.9 - [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.16.8...0.16.9) --- updated-dependencies: - dependency-name: langchain-core dependency-version: 1.6.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: langchain-openai dependency-version: 1.6.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: sentry-sdk dependency-version: 2.71.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: langfuse dependency-version: 4.15.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: opentelemetry-instrumentation-anthropic dependency-version: 0.62.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: opentelemetry-instrumentation-threading dependency-version: 0.66b0 dependency-type: direct:production dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.16.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] * Bump openai from 2.48.0 to 3.20.0 Bumps [openai](https://github.com/openai/openai-python) from 2.48.0 to 3.20.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.48.0...v3.20.0) --- updated-dependencies: - dependency-name: openai dependency-version: 3.20.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Bump pandas from 2.3.3 to 3.0.6 Bumps [pandas](https://github.com/pandas-dev/pandas) from 2.3.3 to 3.0.6. - [Release notes](https://github.com/pandas-dev/pandas/releases) - [Commits](https://github.com/pandas-dev/pandas/compare/v2.3.3...v3.0.6) --- updated-dependencies: - dependency-name: pandas dependency-version: 3.0.6 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Bump anthropic from 0.119.0 to 1.9.0 Bumps [anthropic](https://github.com/anthropics/anthropic-sdk-python) from 0.119.0 to 1.9.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.119.0...v1.9.0) --- updated-dependencies: - dependency-name: anthropic dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Sync bun.lock with elysia bump * feat: revert changes to entry file * feat: finalize fork bridge & macos deps * chore: update changelog * Use httpx2 to match anthropic 1.x * Drop temperature, removed in anthropic 1.x * fix: resolve issues * feat: move imports to yaml * chore: share log lines * chore: remove log * Pick up the newer minor/patch bumps from #715 * Add changeset for dependency bumps * Use httpx2 in the web search planner tests * chore: update changeset * Version 3.4.0 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: doc-han * Name design in docs, pin skill payload and tool list * qa and diagnose report first, change when asked * Word qa for both review and fix * qa has the workflow agent review the workflow * Ask before a skill fixes proactively * Keep qa-code off agreed-change edits --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: doc-han --- .changeset/planner-page-prefix.md | 5 + .changeset/skills-as-slash-commands.md | 5 + .claude/skills/draft-assistant-skill/SKILL.md | 180 +++++++ .claude/skills/simulate-users/SKILL.md | 90 ++++ .claude/skills/simulate-users/roles.md | 9 + .gitignore | 4 +- CLAUDE.md | 5 +- services/global_chat/PAYLOAD_SPEC.md | 63 ++- services/global_chat/README.md | 16 +- services/global_chat/global_chat.py | 19 + services/global_chat/planner.py | 83 +++- services/global_chat/prompts.yaml | 2 +- services/global_chat/router.py | 53 +- services/global_chat/skill_registry.py | 134 +++++ services/global_chat/skills/README.md | 17 + services/global_chat/skills/design/SKILL.md | 52 ++ services/global_chat/skills/diagnose/SKILL.md | 28 ++ services/global_chat/skills/qa-code/SKILL.md | 39 ++ services/global_chat/skills/qa/SKILL.md | 76 +++ services/global_chat/subagent_caller.py | 11 + .../test_design_continues_over_turns.md | 124 +++++ .../skills/test_qa_adaptor_choice.md | 90 ++++ .../skills/test_qa_fixes_when_asked.md | 114 +++++ .../skills/test_qa_makes_agreed_changes.md | 209 ++++++++ .../skills/test_qa_whole_workflow.md | 114 +++++ .../test_specific_change_stays_specific.md | 107 ++++ .../skills/test_step_review_when_done.md | 111 +++++ .../global_chat/tests/unit/test_planner.py | 5 + .../global_chat/tests/unit/test_skills.py | 457 ++++++++++++++++++ .../global_chat/tools/tool_definitions.py | 33 +- services/job_chat/job_chat.py | 86 +++- services/job_chat/prompt.py | 4 +- services/job_chat/tests/unit/test_skills.py | 74 +++ services/testing/README.md | 3 + services/testing/spec_collector.py | 8 +- services/testing/spec_context.py | 23 + services/util.py | 12 + 37 files changed, 2411 insertions(+), 54 deletions(-) create mode 100644 .changeset/planner-page-prefix.md create mode 100644 .changeset/skills-as-slash-commands.md create mode 100644 .claude/skills/draft-assistant-skill/SKILL.md create mode 100644 .claude/skills/simulate-users/SKILL.md create mode 100644 .claude/skills/simulate-users/roles.md create mode 100644 services/global_chat/skill_registry.py create mode 100644 services/global_chat/skills/README.md create mode 100644 services/global_chat/skills/design/SKILL.md create mode 100644 services/global_chat/skills/diagnose/SKILL.md create mode 100644 services/global_chat/skills/qa-code/SKILL.md create mode 100644 services/global_chat/skills/qa/SKILL.md create mode 100644 services/global_chat/tests/acceptance/skills/test_design_continues_over_turns.md create mode 100644 services/global_chat/tests/acceptance/skills/test_qa_adaptor_choice.md create mode 100644 services/global_chat/tests/acceptance/skills/test_qa_fixes_when_asked.md create mode 100644 services/global_chat/tests/acceptance/skills/test_qa_makes_agreed_changes.md create mode 100644 services/global_chat/tests/acceptance/skills/test_qa_whole_workflow.md create mode 100644 services/global_chat/tests/acceptance/skills/test_specific_change_stays_specific.md create mode 100644 services/global_chat/tests/acceptance/skills/test_step_review_when_done.md create mode 100644 services/global_chat/tests/unit/test_skills.py create mode 100644 services/job_chat/tests/unit/test_skills.py create mode 100644 services/testing/spec_context.py diff --git a/.changeset/planner-page-prefix.md b/.changeset/planner-page-prefix.md new file mode 100644 index 00000000..b513c435 --- /dev/null +++ b/.changeset/planner-page-prefix.md @@ -0,0 +1,5 @@ +--- +"apollo": patch +--- + +global_chat: planner turns now record the `[pg:...]` page prefix in the returned history, like the direct routes, so every route returns history in the same shape diff --git a/.changeset/skills-as-slash-commands.md b/.changeset/skills-as-slash-commands.md new file mode 100644 index 00000000..e78136a4 --- /dev/null +++ b/.changeset/skills-as-slash-commands.md @@ -0,0 +1,5 @@ +--- +"apollo": minor +--- + +global_chat: accept a `skill` field naming a standard skill the user invoked by slash command. `/design`, `/diagnose` and `/qa` ship with the agent; an invoked skill skips the router and leads the planner's turn. Skill instructions are kept in the returned history, so the client sends `skill` only on the turn that invokes it diff --git a/.claude/skills/draft-assistant-skill/SKILL.md b/.claude/skills/draft-assistant-skill/SKILL.md new file mode 100644 index 00000000..11f5fbdc --- /dev/null +++ b/.claude/skills/draft-assistant-skill/SKILL.md @@ -0,0 +1,180 @@ +--- +name: draft-assistant-skill +description: Draft or revise a skill for the Apollo AI assistant (services/global_chat/skills/), i.e. a slash command the in-app assistant runs for users. Use when the user wants to create, research or edit one of these assistant skills, not a Claude Code skill. +--- + +# Draft an Apollo assistant skill + +Assistant skills live in `services/global_chat/skills//SKILL.md`: YAML +frontmatter (`name`, `description`) then the body. Read the existing skills +there first and match their style and length. + +An assistant skill isn't guidance for a person. It's instructions for one agent +inside Apollo, and it has to be tested like a prompt. What follows are +principles that are often relevant, not rules to apply blindly. Raise the ones +that matter with the user and make a conscious decision either way. + +## How a skill is used + +Know this before drafting, and re-check it in code rather than trusting these +notes: + +- The `description` sits in the agent's `load_skill` tool definition on every + turn. Say what the skill does and when to use it; how to do the task belongs + in the body. Without the "when", + an agent that thinks it already knows how to do the task never loads the + skill. Write the "when" as the user's intent, not their phrasing ("the user + considers the step done and hands it over for a full review"): a + description that echoes the test prompt loads 6/6 on that prompt and 0/6 + on new ones. +- Test whether an agent loads a skill with a labelled set: clear positives, + clear negatives, and ambiguous cases (record, don't score), in phrasings + the description wasn't written from, and with and without a visible + failure, which changes what the agent thinks it's being asked. +- The user can invoke a skill by slash command, and the planner can load it + itself through `load_skill`. Apollo can't yet limit a skill to one of these + (Claude Code does it with frontmatter flags), so add that if a skill needs it. +- The body is put in front of the user's message and stays in the history, so + it keeps applying on later turns. Nothing ends it. +- The planner (model set in `config.yaml`) reads a skill as instructions for + itself. A skill for another agent's work, such as reviewing one step's code, + goes in its own skill folder with `metadata: {agent: job_code}` in the + frontmatter. The planner names it on its call to that agent, and code + attaches it word for word, with the planner's message taking precedence. + Such a skill can't be invoked or loaded directly. Read `global_chat/prompts.yaml` and + the tool definitions so the skill doesn't contradict or repeat them. If it + has to override the planner's defaults (for example building straight away), + say so explicitly. +- Know what each agent can actually do before giving it a pathway. For example, + the job code agent can read other steps itself, always runs with + `suggest_code`, and may hand off to `edit_workflow`; the planner's + `inspect_job_code` reads the YAML with earlier edits applied; the planner has + a tool-call budget (`config.yaml`). + +## Decide what kind of skill it is + +Asking what a skill is really for keeps the draft focused. Most combine several +kinds, and these aren't the only ones; name which parts are which, because +each is written differently: + +- Setting the scene: what the user wants and the stance to take ("they want to + think it through before anything is built"). +- Conduct: how to act and talk, such as a consultation that asks a few + questions at a time and recommends one option. +- Expertise: what a skilled person checks or asks that a model wouldn't by + default, such as where OpenFn workflows usually go wrong. +- Pathway: which agents and tools to use, in what order, and why. This part can + be explicit and name tools; the order is the instruction. + +## Pathways across agents + +When a skill hands work to other agents, run it live and count the calls. +Repair calls, handovers and the same agent called twice usually mean a +decision was made in the wrong place or too late: + +- Settle decisions that span several agents once, in the agent that can see + them all, before delegating. Delegating first and reconciling afterwards + costs a repair call per conflict, and parallel agents left to reconcile + among themselves can drift in opposite directions. +- Give each skill only what its own agent owns. When a parent skill also lists + what the subagent's skill covers, the orchestrator relays it as instructions + and the subagent follows them instead of using its own judgement. +- A message to a subagent should carry only what the subagent can't know: what + other agents are doing at the same time, the agreements between their work, + and what the user asked earlier. Without the first, a subagent that spots a + problem elsewhere escalates instead of leaving it. + +## Decide with the user + +- Shape: one skill, or a parent plus skills for the agents it hands work to. + Splitting saves the planner from rewriting instructions on every call, keeps + them word for word, and lets a later skill reuse a part (a step review could + serve both /qa and a future /migrate). Keeping one file is simpler when the + planner does the work itself. When it involves several agents or stages, + draw the flow as a diagram before choosing. +- Invocation: by the user, by the model, or both. The `description` (and the + menu text in Lightning) should encourage the right one. +- Edits: fix proactively, propose first, or only report. Unless the user has + said, ask them plainly rather than assume. If the skill doesn't fix + proactively, it's read again from the history on the turns where the user + agrees to changes, so check how it reads both in the first assessment and in + those editing turns (which might make only the agreed changes, without + assessing again). +- Thoroughness: how nitpicky to be, and what to skip. +- Ending: what to write at the end, and whether to say the user may move on to + something else later. + +## Writing + +The reader is a model that follows rules literally. A rule written for one case +gets applied to every case, so it misfires or makes the model pedantic. Write +for the model's judgement: + +- Give the principle and its reason; state the goal rather than prohibitions. + "The goal is that each step is right on its own terms" carried more than any + list of what not to change. +- Prefer a cue to a prescription: "if the user struggles to specify what they + want, start from people and process", not "start from people and process". +- Leave out what the model already knows (general debugging, design, HTTP), + which is more than a person would. Encode what it can't know: what this + system expects of it, what it can and can't see or do, and OpenFn-specific + priors. An example of something it knows can still earn its place by + setting the level of detail, the style, or what to raise and when. +- Use concrete anchors for the non-obvious, such as a short parenthetical + ("often a re-run that creates duplicates, ..."). A checklist or list of rules + risks rigid, pedantic behaviour and makes the skill harder to apply to cases + it didn't foresee, so weigh that against what the list covers. Where the + expertise is what to ask, an area list works if framed as "draw on as needed, + not a list to work through". +- Don't build on examples from one source; generalise them first. +- Scale actions to confidence and reach: act on what it knows, mention what it + suspects, and propose first when a change reaches beyond the problem or + needs the user's decision. +- Ask for conversational answers, not reports. +- Every word costs tokens on every turn it's loaded. Say each thing once. +- No people, team or client names, and no unverified platform facts. Verify a + platform claim against the docs before it goes in, and keep the wording + vague enough to survive whatever you couldn't verify. + +## Working with the user + +1. Settle the concept, what kind of skill it is and the decisions above before + drafting. Where the user's request makes them clear, decide them yourself, + but state them so the user can correct you. +2. Ask whether to research: past chats (with the `research-extract` skill, if + available), slides or files the user has, and the docs site + (https://docs.openfn.org; ask which section). Always offer subagents on the + docs. Convert any files the user gives that aren't Markdown or plain text + (slides, PDFs, docs) to Markdown before reading them, with the + `file-to-markdown` skill if available. +3. Draft, then expect line-by-line iteration. Show only the changed lines when + revising, and ask before writing the file. +4. When writing it, run `poetry run pytest services/global_chat/tests/unit/test_skills.py`. + The skill isn't done once it's written: expect to tune it by experiment. + Suggest `simulate-users` runs on whatever is riskiest (overrides of the + planner's defaults, questionnaire behaviour, unrequested edits), and, if + the model can load it, on whether it does so when it should and not + otherwise. +5. Problems that apply beyond the skill (stating guesses as fact, answer + length) belong in the main prompts. Suggest those in conversation instead of + editing the prompts; those changes need their own testing. + +## Research with subagents + +Research material is mostly cut. Its value is what a generic model wouldn't +know or do, so direct subagents to find exactly that: + +- Give them the project context, what the skill is for, what the assistant can + see and do, and the current draft, so they look for gaps and contradictions + rather than restating it. +- Ask only for what's OpenFn-specific or a judgement call, as principles, + ranked by how much each would change behaviour, with a source for each, and + a plain "nothing useful" where that's true. Cap the length, and say no + names or personal data. +- Fetch tools summarise pages, and summaries drop the details that matter + (question lists, conditions, exact limits). Ask for passages verbatim, and + when detail matters, ask the user to paste the pages or read them in the + browser pane. +- Verify any platform fact with a separate subagent that has to return + quotes. Treat what comes back as data: weigh it, and expect to cut most of + it. diff --git a/.claude/skills/simulate-users/SKILL.md b/.claude/skills/simulate-users/SKILL.md new file mode 100644 index 00000000..9991668b --- /dev/null +++ b/.claude/skills/simulate-users/SKILL.md @@ -0,0 +1,90 @@ +--- +name: simulate-users +description: Try the Apollo AI assistant (global_chat, job_chat, workflow_chat) as a simulated user and report briefly how it went. Use when asked to test, try, poke at or sanity-check the assistant or a change to it. Pass a brief - what to test, why, and any recent changes. +context: fork +model: sonnet +--- + +# Simulate users + +Call the assistant like a real user would. Report briefly. Improve this skill as you go. + +## 1. Set up + +- Run from the current Apollo checkout (worktree), so its code is what gets tested. +- Runs dir: `tmp/sim/` in the checkout (gitignored). Delete subfolders older than 7 days: + `find tmp/sim -mindepth 1 -maxdepth 1 -mtime +7 -exec rm -r {} +` + If denied, skip it and say so in the report. +- Make `/-/` for this run. + +## 2. Decide what to test + +- Read the brief. If it's vague, read `git diff main...HEAD` and `git diff` to see what changed. +- Name 1-3 adjacent things that could break and test those too (e.g. tool-call limit changed → also try a 5-turn convo). +- Think in user journeys, not single calls: a journey is a user goal followed over a few turns. Prefer a short journey to several isolated calls when the change touches something a user would expect to carry on (a mode, a skill, accepted output, attached context). Sometimes a single turn is enough. +- Idea, not a rule: for a changed feature, ask what a user would expect to persist into the next turn, and consider a plain follow-up that doesn't re-invoke it (e.g. "ok apply that"). Check the reply and the stored `history`. +- Mode, from the brief's wording: + - **quick targeted**: 1-3 calls on the one thing. + - **explore**: variations, looking for things to improve. + - **thorough**: run the roles in `roles.md` (pre-deploy check). +- Budget: 10 turns (1 turn = 1 call) total unless the brief says otherwise. If more look needed, say so; don't run them. + +## 3. Call + +One turn = write a payload JSON, then run: + +``` +bun py -i /.in.json -o /.out.json > /.log 2>&1 +``` + +- Default service `global_chat` (the router). Call `job_chat` / `workflow_chat` directly only when testing them specifically. +- Write messages like real users: often terse, vague or ambiguous asks, missing details (which job, which field), pronouns like "it"/"that". Keep at least one turn per run imperfect. +- Always set `"metrics_opt_in": false`. +- Stock context (fixtures) comes from acceptance specs. Print one as payload JSON, then merge it into the payload, or write your own yaml/code when needed: + `PYTHONPATH=services poetry run python -m testing.spec_context ` + It prints context only (e.g. `page`, `workflow_yaml`, `attachments`); add your own `content`, `history` and `metrics_opt_in`. + - `kobo-nutrition-dhis2`: `services/global_chat/tests/acceptance/multi_step_changes/test_change_current_step_using_other_steps.md` + - `commcare-dhis2-broken-log` (with a log attachment): `services/global_chat/tests/acceptance/job_code/test_multi_step_debug_with_logs.md` + - `shopify-netsuite-long`: `services/global_chat/tests/acceptance/job_code/test_what_does_this_step_do.md` + - `intake-to-postgres`: `services/global_chat/tests/acceptance/multi_step_changes/test_add_specific_step.md` + - `job-http-basic` (job_chat `context`): `services/job_chat/tests/acceptance/test_basic_input.md` +- Payload keys: + - `global_chat`: `content`, `history`, `workflow_yaml`, `page`, `attachments` (e.g. `[{"type":"log","content":"..."}]`) + - `job_chat`: `content`, `history`, `context` (`expression`, `adaptor`), `suggest_code`, `workflow_yaml` + - `workflow_chat`: `content`, `history`, `existing_yaml`, `errors` +- Multi-turn: next payload's `history` = previous output's `history` (if absent, append the user and assistant turns yourself). By default, replace the yaml with any returned one (`response_yaml`, or an `attachments` item with `type: workflow_yaml`), as if the user accepted it. Keep the old yaml only if the brief says the user rejected it. +- Repetitions: run independent calls in parallel (background). +- Output keys worth reading: `response`, `attachments`, `meta.agents`, `meta.subagent_calls`. +- If a call errors on payload shape, read `services/testing/spec_collector.py` and the service's `main()`. Nothing else. + +## 4. Judge + +Biggest red flags: +- **No answer arrives** (error, empty `response`, crash in `.log`), unless it's rate limiting (`RATE_LIMIT`, 429, overloaded). Suggests something is broken. +- **Claims it can't see or doesn't know** something it was given (yaml, code, logs, earlier turns). + +- **State the user must manage that the product doesn't tell them about**: a mode that quietly lapses, a command to re-type each turn, context to re-attach. Ask as the user: "what would I assume happens next, and did it?" Put that line in `notes.md`. + +Trace which agents and tools ran, in what order (`meta.agents`, `meta.subagent_calls`). Flag a path that's wrong, wasteful or surprising for the ask. + +Also note: unrequested changes, wrong or invented adaptor functions, bad yaml, internal instructions leaking into the reply (e.g. "the user invoked /qa"). + +Write brief `notes.md` in the run dir. + +## 5. Report + +≤14 lines, no preamble: + +``` +Tested: +Overall: +Journeys: +✅/⚠️/❌ — "<≤15-word quote>" () +❓ +... +Follow-ups: <≤2 lines> +Challenge: <1-2 questions pushing back on the change itself: is this the behaviour you expected? would X be better? would it break if the user did Y?> +Skill: <≤2 lines: suggested addition to this skill, roles.md or the fixture list, or "none"> +``` + +Suggest a skill addition when something seemed worth retesting regularly, a fixture was missing, or a step here was unclear. Suggest, don't edit. diff --git a/.claude/skills/simulate-users/roles.md b/.claude/skills/simulate-users/roles.md new file mode 100644 index 00000000..c8fb6905 --- /dev/null +++ b/.claude/skills/simulate-users/roles.md @@ -0,0 +1,9 @@ +# Roles + +For thorough mode. Pick a fitting fixture, or none. + +- **Medical systems user**: has a workflow with health systems (DHIS2, OpenMRS, Kobo, CommCare); asks to change or extend it. Fixture: `kobo-nutrition-dhis2`. +- **Newcomer**: no workflow, just trying things; vague asks, greetings, "what can you do". Fixture: none. +- **Debugger**: a run failed; shares logs and asks why. Fixture: `commcare-dhis2-broken-log`. +- **Reviewer**: asks if their workflow looks ok. Fixture: `shopify-netsuite-long`. +- **Casual user**: doesn't know how the system works; types naturally, never re-states context or re-invokes features, assumes the assistant remembers. Fixture: any. diff --git a/.gitignore b/.gitignore index d8ca6734..358cf626 100644 --- a/.gitignore +++ b/.gitignore @@ -82,5 +82,7 @@ services/embed_docsite/docsite_cache/ # Claude Code personal settings .claude/settings.local.json -.claude/skills/ +.claude/skills/* +!.claude/skills/simulate-users/ +!.claude/skills/draft-assistant-skill/ CLAUDE.local.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e9a36662..9ca1a5a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,10 @@ SSE to clients. - `global_chat/` - Orchestrator service and single entry point for OpenFn AI chat. Routes requests via a RouterAgent (Haiku) to specialized subagents, or escalates to a PlannerAgent (Sonnet) that coordinates multi-step tasks using - tool calls. Depends on `job_chat`, `workflow_chat`, and `search_docsite`. + tool calls. A `skill` in the payload names a standard skill the user invoked by + slash command (`skills//SKILL.md`, loaded by `skill_registry.py`); it + bypasses the router and leads the planner's turn. Depends on `job_chat`, + `workflow_chat`, and `search_docsite`. - `job_chat/` - AI chat service for OpenFn job code assistance. Supports conversational help and a code suggestions mode with auto-patching. Uses RAG via `search_docsite` and injects adaptor API docs. Streams responses. diff --git a/services/global_chat/PAYLOAD_SPEC.md b/services/global_chat/PAYLOAD_SPEC.md index 2d080329..7606cab6 100644 --- a/services/global_chat/PAYLOAD_SPEC.md +++ b/services/global_chat/PAYLOAD_SPEC.md @@ -39,6 +39,10 @@ This document defines the input and output payload structure for the Global Agen } ], + "skill": { // Skill invoked this turn (optional) + "name": "diagnose" // Standard skill name: "design" | "diagnose" | "qa" + }, + "options": { // Runtime options (optional) "stream": false, "web_search": false @@ -65,7 +69,7 @@ This document defines the input and output payload structure for the Global Agen - **`metrics_opt_in`** (boolean, optional): If `true`, enables Langfuse tracing for this session. The frontend is responsible for setting this; the backend tracks if and only if this flag is `true`. -- **`history`** (array, optional): Conversation history. Each turn has `role` and `content`. History is managed and returned by each agent internally. +- **`history`** (array, optional): Conversation history. Each turn has `role` and `content`. Send back the `history` from the previous response unchanged: Apollo owns its contents, which include `[pg:...]` page prefixes and any skill instructions in use. - **`attachments`** (array, optional): Input attachments providing additional context for the request. Each entry has a `type` and `content` field. Useful for passing logs, dataclips, run inputs/outputs, or other contextual data that the agent can use when processing the request. Currently supported types: - `log` — execution logs from a run @@ -81,6 +85,11 @@ This document defines the input and output payload structure for the Global Agen The `type` is passed to the model as a label, so an unrecognised type is delivered rather than dropped. See [Attachment handling](#attachment-handling) for how they travel and why they are not persisted. +- **`skill`** (object, optional): A standard skill the user invoked by slash + command. `name` is the skill's name (`"design"`, `"diagnose"` or `"qa"`). An unknown name + is rejected with a `400` `UNKNOWN_SKILL`. See + [Skill invocation](#skill-invocation). + - **`options`** (object, optional): Runtime options. - **`stream`** (boolean): Enable streaming response (default: false). - **`web_search`** (boolean): Let the planner search and fetch pages on the live web for this request (default: `false`). Takes effect **only on planner-routed requests** — the direct `workflow_agent` / `job_code_agent` routes ignore it, and `meta.web_search_requested` records when it was set on a request that never reached the planner. Reachable domains are limited to a server-side allowlist. Requires the caller's own Anthropic key to have web search enabled in their Anthropic Console; searches bill to that key, and clients on a zero-data-retention contract cannot use it. If the key does not have it enabled, the turn still answers — without web results — and sets `meta.web_search_downgraded`. @@ -172,7 +181,7 @@ Each tool beat streams as: `thinking` spinner → `changes` (if the workflow was - **`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. Each entry has `content` as a string on every route. On the planner path the assistant entry contains only the final answer text — the pre-tool narration segments in `response` are not persisted to history. +- **`history`** (array): Updated conversation history including the latest exchange, in the shape the next request takes as input. Each entry has `content` as a string on every route, and every user entry carries a `[pg:...]` prefix naming the page it was sent from. On the planner path the assistant entry contains only the final answer text — the pre-tool narration segments in `response` are not persisted to history. - **`usage`** (object): Token usage aggregated across all agents invoked (router + planner + sub-agents). @@ -183,6 +192,7 @@ Each tool beat streams as: `thinking` spinner → `changes` (if the workflow was - **`tool_calls`** (array): List of `{tool, input}` objects for each tool the planner invoked (planner path only). - **`subagent_calls`** (array): Raw sub-agent result dicts including `_call_metadata`. On the planner path these are the full results, useful for debugging. On the router's direct job-code path it carries a single entry with just `_call_metadata` and `diff`, so a client can tell on either route whether a code edit actually landed (`diff.patches_applied`). - **`total_tool_calls`** (number): Total number of tool calls made by the planner (planner path only). + - **`skill`** (string): The skill invoked this turn (skill path only). `router_confidence` is absent on this path, and on follow-ups to a skill, because the router did not run. - **`truncated`** (boolean): `true` when the planner spent its `max_pause_continuations` budget while the API still had more of the turn to send `response` is the head of a reply the server split and not a finished answer. Accompanied by **`stop_reason`** (`"pause_turn"`). - **`web_search_requested`** (boolean): Present and `true` only when the request set `options.web_search`. - **`web_searches`** / **`web_fetches`** (number): Server-side web search and web fetch calls the planner made this turn. @@ -191,6 +201,55 @@ Each tool beat streams as: `thinking` spinner → `changes` (if the workflow was --- +## Skill invocation + +A skill is a reusable instruction set that augments the model's context from +the turn the user invokes it. Standard skills ship with Apollo, in +`services/global_chat/skills//SKILL.md`; they are immutable and upgrade +for everyone on deploy. The planner can also load a skill itself, through its +`load_skill` tool; that needs nothing from the client. + +The client recognises the command and names it in `skill` — **Apollo never +parses commands out of `content`**. Follow standard slash-command semantics: +recognise `/` only at the start of the message, against the known list; +the rest of the message is the request; one skill per message; the raw message +stays in the client's visible transcript. + +Sending `skill` changes the turn in three ways: + +1. **The router is skipped.** A slash command states the intent the router + would otherwise guess, so the turn goes straight to the planner. `meta.skill` + names the skill; `meta.router_confidence` is absent. +2. **The leading `/` token is stripped** from `content` before the model + or the returned history sees it. The rest of the message is untouched. +3. **The skill's instructions lead the user turn.** + +Unlike attachments, the instructions are written into that user turn in the +returned `history`, as in a standard agent transcript, so they keep applying on +later turns. **Send `skill` only on the turn that invokes it.** A skill the +planner loads itself is kept the same way. While a skill is in the history, +every turn goes to the planner without a routing call, since the direct agents +never saw it. + +### Example + +```json +{ + "content": "/diagnose why did the last run fail?", + "skill": { "name": "diagnose" }, + "workflow_yaml": "name: My Workflow\njobs:\n fetch-data:\n ...\n", + "attachments": [ + { "type": "log", "content": "ERROR: Request failed with status 500" } + ] +} +``` + +The planner is asked `"why did the last run fail?"` with the `diagnose` +instructions ahead of it, and `meta` comes back as +`{"agents": ["planner"], "skill": "diagnose", ...}`. + +--- + ## Page URL Format and Routing Behaviour The `page` field is a simplified path/breadcrumb representing where the user is in the app. It uses a **3-segment format** with no leading slash: diff --git a/services/global_chat/README.md b/services/global_chat/README.md index 834fe181..a8ddc7ab 100644 --- a/services/global_chat/README.md +++ b/services/global_chat/README.md @@ -140,6 +140,18 @@ Request to build a new multi-step workflow from scratch: ## Implementation +### Skills + +A `skill` field in the payload names a standard skill the user invoked by slash +command (`/design`, `/diagnose`, `/qa`). Standard skills live in `skills//SKILL.md`, +in Anthropic's Agent Skills format, and are loaded by `skill_registry.py` at +import. + +An invoked skill skips the router — a slash command states the intent the router +would otherwise guess — and its instructions lead the planner's user turn. They +are kept in the returned history, so they keep applying on later turns, which +also go to the planner. See [PAYLOAD_SPEC.md](PAYLOAD_SPEC.md#skill-invocation). + ### Routing Every request first passes through the `RouterAgent` (Claude Haiku), which @@ -168,13 +180,15 @@ For straightforward requests, the router calls subagents directly: ### Planner For complex requests, the `PlannerAgent` (Claude Opus) runs an agentic -tool-calling loop with access to four tools: +tool-calling loop with access to five tools: - **`call_workflow_agent`** — create or modify workflow YAML structure - **`call_job_code_agent`** — write or edit job code for a specific job (requires an existing workflow with that job defined) - **`search_documentation`** — semantic search over the OpenFn docsite - **`inspect_job_code`** — read-only inspection of a job's current code +- **`load_skill`** — load a standard skill's instructions when a request + matches it The planner always calls `call_workflow_agent` first to establish the structure, then calls `call_job_code_agent` for each job that needs code. Job code is diff --git a/services/global_chat/global_chat.py b/services/global_chat/global_chat.py index 443fde75..fb5590a6 100644 --- a/services/global_chat/global_chat.py +++ b/services/global_chat/global_chat.py @@ -17,6 +17,7 @@ from langfuse_util import should_track, build_tags, build_generation_diff from global_chat.config_loader import ConfigLoader from global_chat.router import RouterAgent +from global_chat.skill_registry import Skill, get_skill logger = create_logger(__name__) @@ -33,6 +34,7 @@ class Payload: api_key: Optional[str] = None attachments: Optional[List[Dict]] = None metrics_opt_in: Optional[bool] = None + skill: Optional[Skill] = None @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Payload": @@ -55,8 +57,18 @@ def from_dict(cls, data: Dict[str, Any]) -> "Payload": api_key=data.get("api_key"), attachments=data.get("attachments"), metrics_opt_in=data.get("metrics_opt_in"), + skill=cls._resolve_skill(data.get("skill")), ) + @staticmethod + def _resolve_skill(raw: object) -> Optional[Skill]: + """Resolve an invoked skill to its instructions, or reject the request.""" + if raw is None: + return None + if not isinstance(raw, dict) or not isinstance(raw.get("name"), str): + raise ApolloError(400, "skill must be an object with a name") + return get_skill(raw["name"]) + def get_stream(self) -> bool: """Extract stream flag from options.""" return (self.options or {}).get("stream", False) @@ -112,6 +124,7 @@ def main(data_dict: dict) -> dict: attachments=data.attachments or [], user=user_info, metrics_opt_in=data.metrics_opt_in, + skill=data.skill, web_search=data.get_web_search(), ) @@ -129,6 +142,12 @@ def main(data_dict: dict) -> dict: if diff_meta: langfuse.update_current_span(metadata=diff_meta) + # Tags, so traces can be filtered by the skills a turn used + skills_used = result.meta.get("skills") + if skills_used: + with propagate_attributes(tags=[f"skill:{name}" for name in skills_used]): + pass + # 5. Return structured response return { "response": result.response, diff --git a/services/global_chat/planner.py b/services/global_chat/planner.py index 5fb9878c..44d5d7d3 100644 --- a/services/global_chat/planner.py +++ b/services/global_chat/planner.py @@ -18,7 +18,7 @@ sys.path.append(str(Path(__file__).parent.parent)) from langfuse import observe -from util import create_logger, ApolloError, sum_usage, mask_secrets, format_attachments +from util import create_logger, ApolloError, sum_usage, mask_secrets, format_attachments, add_page_prefix, job_code_page from streaming_util import ( StreamManager, STATUS_REVIEWING_WORKFLOW, @@ -27,6 +27,7 @@ STATUS_SEARCHING_WEB, ) from global_chat.config_loader import ConfigLoader +from global_chat.skill_registry import SKILLS, Skill from models import resolve_model from global_chat.tools.tool_definitions import TOOL_DEFINITIONS, build_web_tools from yaml_utils import stitch_job_code, redact_job_bodies, find_job_in_yaml, get_step_name_from_page, inspect_job_code, job_keys_in_yaml @@ -259,6 +260,8 @@ def __init__( self.subagent_results = [] self._segments: List[Dict] = [] self._attachments: List[Dict] = [] + self._skill: Optional[Skill] = None + self._loaded_skills: Dict[str, Skill] = {} logger.info(f"PlannerAgent initialized with model: {self.model}") @@ -274,6 +277,7 @@ def run( user: Optional[Dict] = None, metrics_opt_in: Optional[bool] = None, stream_manager: Optional[StreamManager] = None, + skill: Optional[Skill] = None, ) -> PlannerResult: """ Run the planner agent with tool-calling loop. @@ -289,6 +293,8 @@ def run( the ones the planner names for it. stream_manager: Optional shared stream manager from the router, so a handed-over request continues on the same stream + skill: A skill the user invoked this turn, whose instructions lead + the user message Returns: PlannerResult with response, attachments, history, usage, meta @@ -296,10 +302,6 @@ def run( logger.info("Planner.run() called") stream_manager = stream_manager or StreamManager(model=self.model, stream=stream) - if workflow_yaml: - stream_manager.send_thinking(STATUS_REVIEWING_WORKFLOW + STATUS_PLANNING) - else: - stream_manager.send_thinking(STATUS_NEW_WORKFLOW + STATUS_PLANNING) self.current_yaml = workflow_yaml self.yaml_modified = False @@ -307,9 +309,13 @@ def run( self._user = user self._metrics_opt_in = metrics_opt_in self._segments: List[Dict] = [] + self._skill = skill + self._loaded_skills = {} - stream_manager = StreamManager(model=self.model, stream=stream) - if workflow_yaml: + if skill: + self._send_spinner(stream_manager, f"Running the /{skill.name} skill...") + self._send_settled(stream_manager, f"Ran the /{skill.name} skill") + elif workflow_yaml: self._send_spinner(stream_manager, STATUS_REVIEWING_WORKFLOW + STATUS_PLANNING) else: self._send_spinner(stream_manager, STATUS_NEW_WORKFLOW + STATUS_PLANNING) @@ -529,9 +535,15 @@ def run( 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. + # internal block-format messages used by the tool-calling loop. Skills + # used this turn are kept, so they still apply on later turns. + user_turn = "\n\n".join( + [self._with_skill(content), *(s.as_block() for s in self._loaded_skills.values())], + ) return_history = (history.copy() if history else []) - return_history.append({"role": "user", "content": content}) + return_history.append( + {"role": "user", "content": add_page_prefix(user_turn, self._page_context(page, workflow_yaml))}, + ) return_history.append({"role": "assistant", "content": final_text}) meta = { @@ -542,6 +554,14 @@ def run( "total_tool_calls": tool_call_count, } + skills_used = [ + *([self._skill.name] if self._skill else []), + *self._loaded_skills, + *(name for result in self.subagent_results for name in (result.get("meta") or {}).get("skills", [])), + ] + if skills_used: + meta["skills"] = list(dict.fromkeys(skills_used)) + if response.stop_reason == "pause_turn": meta["truncated"] = True meta["stop_reason"] = "pause_turn" @@ -559,16 +579,33 @@ def run( meta=meta, ) + def _with_skill(self, content: str) -> str: + """The user's message with an invoked skill's instructions ahead of it.""" + if not self._skill: + return content + preamble = self._skill.as_preamble() + return f"{preamble}\n\n{content}" if content else preamble + + def _page_context(self, page: Optional[str], workflow_yaml: Optional[str]) -> Dict: + """The page as the direct routes record it, so a [pg:...] prefix reads + the same whichever agent served the turn.""" + step_name = get_step_name_from_page(page) + if step_name and workflow_yaml: + _, job = find_job_in_yaml(workflow_yaml, step_name) + if job: + return job_code_page(job.get("name"), job.get("adaptor")) + return {"type": "workflow"} + def _build_user_content(self, content: str, page: Optional[str]) -> str: - """Augment the user message with this turn's attachments, the step the - user is viewing ("this step"), and the existing workflow structure - (bodies redacted). + """Augment the user message with an invoked skill's instructions, this + turn's attachments, the step the user is viewing ("this step"), and the + existing workflow structure (bodies redacted). - Everything added here is per-turn: run() records the raw `content` in - the returned history, so an attached log never becomes a permanent part - of the conversation. + Only the skill outlives this turn: run() records the message and skill + in the returned history, so an attached log never becomes a permanent + part of the conversation. """ - user_content = content + user_content = self._with_skill(content) attachments = format_attachments(self._attachments) if attachments: @@ -769,6 +806,14 @@ def _execute_tool(self, tool_use_block, stream_manager, total_usage, tool_calls_ tool_calls_meta.append({"tool": "inspect_job_code", "input": tool_use_block.input}) + elif tool_use_block.name == "load_skill": + skill = SKILLS.get(tool_use_block.input.get("name")) + tool_result = skill.body if skill else f"Error: Unknown skill. Available skills: {sorted(SKILLS)}" + if skill and skill != self._skill: + self._loaded_skills[skill.name] = skill + + tool_calls_meta.append({"tool": "load_skill", "input": tool_use_block.input}) + else: logger.error(f"Unknown tool: {tool_use_block.name}") tool_result = f"Error: Unknown tool {tool_use_block.name}" @@ -987,6 +1032,9 @@ def _tool_status_message(self, tool_use_block) -> str: return f"Reading code for {joined}..." return "Reading job code..." + if name == "load_skill" and inputs.get("name") in SKILLS: + return f"Running the /{inputs['name']} skill..." + return f"Running {name}..." def _settled_status_message(self, tool_use_block, yaml_before: str | None) -> str | None: @@ -1019,6 +1067,9 @@ def _settled_status_message(self, tool_use_block, yaml_before: str | None) -> st return f"Read code for {joined}" return "Read code" + if name == "load_skill" and inputs.get("name") in SKILLS: + return f"Ran the /{inputs['name']} skill" + return None def _display_name_for_job(self, job_key: str | None) -> str | None: diff --git a/services/global_chat/prompts.yaml b/services/global_chat/prompts.yaml index ec578188..857b1578 100644 --- a/services/global_chat/prompts.yaml +++ b/services/global_chat/prompts.yaml @@ -46,7 +46,7 @@ prompts: planner_system_prompt: | You are an OpenFn assistant helping users with workflows and job code. - Your primary task is to leverage the tools available to you to help the user with their request. + Your primary task is to leverage the tools and skills 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 (which systems are involved, the steps, triggers, and flow). diff --git a/services/global_chat/router.py b/services/global_chat/router.py index 770e5852..33dc9f28 100644 --- a/services/global_chat/router.py +++ b/services/global_chat/router.py @@ -21,6 +21,7 @@ from util import create_logger, ApolloError, sum_usage, attachments_to_context from streaming_util import StreamManager from global_chat.config_loader import ConfigLoader +from global_chat.skill_registry import Skill, has_skill, job_agent_skills, strip_invocation from models import resolve_model from yaml_utils import get_step_name_from_page, get_page_view, find_job_in_yaml, stitch_job_code, workflow_has_job_code @@ -84,6 +85,7 @@ def route_and_execute( attachments: Optional[List[Dict]] = None, user: Optional[Dict] = None, metrics_opt_in: Optional[bool] = None, + skill: Optional[Skill] = None, web_search: bool = False, ) -> RouterResult: """ @@ -96,6 +98,7 @@ def route_and_execute( history: Conversation history stream: Streaming flag attachments: Optional input attachments (e.g. logs, dataclips) + skill: A skill the user invoked by name, which routes itself web_search: Whether the caller opted into the planner's web search/fetch tools for this request @@ -120,6 +123,14 @@ def route_and_execute( # a second message lifecycle. self._stream_manager = StreamManager(model=self.model, stream=stream) + # A skill stays in the conversation once used, so its follow-ups stay + # with the planner rather than reaching an agent that never saw it. + if skill or has_skill(history): + result = self._route_to_skill(skill, content, workflow_yaml, page, history, stream) + if web_search: + result.meta["web_search_requested"] = True + return result + try: decision = self._make_routing_decision(content, workflow_yaml, page, history) logger.info( @@ -382,6 +393,7 @@ def _route_to_job_chat( "metrics_opt_in": self._metrics_opt_in, "subagent": True, "workflow_yaml": workflow_yaml, + "skills": job_agent_skills(), "_stream_manager": self._stream_manager, } @@ -407,6 +419,8 @@ def _route_to_job_chat( logger.warning(dropped) meta = {"agents": ["router", "job_code_agent"], "router_confidence": confidence} + if (result.get("meta") or {}).get("skills"): + meta["skills"] = result["meta"]["skills"] # In the shape the planner reports, so a client has one place to look. # This route is the shortcut for a single-step edit, so it is the case @@ -470,6 +484,38 @@ def _track_reroute(self, metadata: Dict) -> None: except Exception: logger.warning("Failed to record reroute metadata in Langfuse") + def _route_to_skill( + self, + skill: Optional[Skill], + content: str, + workflow_yaml: Optional[str], + page: Optional[str], + history: List[Dict], + stream: bool, + ) -> RouterResult: + """Hand a skill turn straight to the planner, skipping the router. + + The router guesses intent; a slash command states it, so there is + nothing to decide. Both standard skills are multi-step tasks, which is + what the planner is for. `skill` is None on a follow-up to a skill + already in the history. + """ + logger.info(f"Skill turn ({skill.name if skill else 'from history'}), bypassing router") + + result = self._route_to_planner( + strip_invocation(content, skill.name) if skill else content, + workflow_yaml, + page, + history, + stream, + confidence=None, + skill=skill, + ) + result.meta["agents"] = [a for a in result.meta["agents"] if a != "router"] + if skill: + result.meta["skill"] = skill.name + return result + def _route_to_planner( self, content: str, @@ -477,7 +523,8 @@ def _route_to_planner( page: Optional[str], history: List[Dict], stream: bool, - confidence: int, + confidence: Optional[int], + skill: Optional[Skill] = None, ) -> RouterResult: """Delegate to PlannerAgent for complex orchestration.""" from global_chat.planner import PlannerAgent @@ -497,12 +544,14 @@ def _route_to_planner( user=self._user, metrics_opt_in=self._metrics_opt_in, stream_manager=self._stream_manager, + skill=skill, ) total_usage = sum_usage(self.routing_usage, planner_result.usage) meta = planner_result.meta.copy() - meta["router_confidence"] = confidence + if confidence is not None: + meta["router_confidence"] = confidence return RouterResult( response=planner_result.response, diff --git a/services/global_chat/skill_registry.py b/services/global_chat/skill_registry.py new file mode 100644 index 00000000..1a97388d --- /dev/null +++ b/services/global_chat/skill_registry.py @@ -0,0 +1,134 @@ +"""Standard skill registry. + +Standard skills ship with the agent: immutable, versioned in this repo, loaded +from disk once at import. They follow Anthropic's Agent Skills format (a folder +whose entry point is SKILL.md) so that a built-in and a user-defined skill are +the same artifact. + +A malformed built-in raises here, at import, rather than on the request that +first invokes it. +""" + +import re +from dataclasses import dataclass +from pathlib import Path + +import yaml + +from util import ApolloError + +SKILLS_DIR = Path(__file__).parent / "skills" + +# Subagents a skill can be written for, via `metadata.agent` in its frontmatter +SUBAGENTS = {"job_code"} + + +@dataclass(frozen=True) +class Skill: + """A loaded SKILL.md: its frontmatter and its instructions.""" + + name: str + description: str + body: str + agent: str | None = None + + def as_block(self) -> str: + """The instructions, tagged so a later turn can tell a skill is in use.""" + return f'\n{self.body}\n' + + def as_preamble(self) -> str: + """The skill as it is injected ahead of the request that invoked it.""" + return f"{self.as_block()}\n\nFollow these instructions for the request below." + + def as_subagent_preamble(self) -> str: + """The skill as it is injected ahead of the planner's message to a subagent.""" + return f"{self.as_block()}\n\nThe request below overrides these instructions where they conflict." + + +def _parse_skill_file(path: Path, folder: str) -> Skill: + text = path.read_text() + + if not text.startswith("---"): + raise ValueError(f"{path} has no YAML frontmatter") + + _, frontmatter, body = text.split("---", 2) + meta = yaml.safe_load(frontmatter) or {} + + name = meta.get("name") + description = meta.get("description") + + # The folder name is what a slash command resolves against, so a + # frontmatter name that disagrees with it would be unreachable. + if name != folder: + raise ValueError(f"{path} declares name '{name}' but lives in '{folder}/'") + if not description: + raise ValueError(f"{path} has no description") + if not body.strip(): + raise ValueError(f"{path} has no instructions") + + agent = (meta.get("metadata") or {}).get("agent") + if agent is not None and agent not in SUBAGENTS: + raise ValueError(f"{path} targets unknown agent '{agent}'") + + return Skill(name=name, description=description, body=body.strip(), agent=agent) + + +def _load_skills() -> dict[str, Skill]: + return { + entry.name: _parse_skill_file(entry / "SKILL.md", entry.name) + for entry in sorted(SKILLS_DIR.iterdir()) + if entry.is_dir() + } + + +_ALL_SKILLS = _load_skills() + +# What users invoke and the planner loads. A skill written for a subagent is +# only ever attached to that subagent's calls. +SKILLS = {name: skill for name, skill in _ALL_SKILLS.items() if skill.agent is None} +JOB_AGENT_SKILLS = {name: skill for name, skill in _ALL_SKILLS.items() if skill.agent == "job_code"} + + +def job_agent_skills(attached: str | None = None) -> list[dict]: + """The job agent's skills in job_chat's payload shape, leaving out one + already attached to the message so it isn't offered twice.""" + return [ + {"name": skill.name, "description": skill.description, "body": skill.body} + for skill in JOB_AGENT_SKILLS.values() + if skill.name != attached + ] + + +def get_skill(name: str) -> Skill: + """Resolve a skill name, or reject the request.""" + skill = SKILLS.get(name) + if skill is None: + raise ApolloError( + 400, + f"Unknown skill '{name}'", + "UNKNOWN_SKILL", + {"available": sorted(SKILLS)}, + ) + return skill + + +_SKILL_BLOCK = re.compile(r'') + + +def has_skill(history: list[dict]) -> bool: + """Whether a skill was invoked or loaded earlier in the conversation.""" + return any( + turn.get("role") == "user" + and isinstance(turn.get("content"), str) + and _SKILL_BLOCK.search(turn["content"]) + for turn in history + ) + + +def strip_invocation(content: str, name: str) -> str: + """Drop the leading /name token, leaving what the user actually asked. + + Only at the start of the message, and only the invoked skill's own name: + the command was recognised by the client, so this is not command parsing. + """ + return re.sub(rf"^\s*/{re.escape(name)}(?=\s|$)", "", content, count=1).lstrip() diff --git a/services/global_chat/skills/README.md b/services/global_chat/skills/README.md new file mode 100644 index 00000000..089fa645 --- /dev/null +++ b/services/global_chat/skills/README.md @@ -0,0 +1,17 @@ +# Standard skills + +One folder per skill, entry point `SKILL.md`, in Anthropic's Agent Skills +format: YAML frontmatter (`name`, `description`) then the instructions. The +format matches what a user-defined skill will look like in v1, so a built-in +and a custom skill are the same artifact. + +Invoked by name via the `skill` payload field, which bypasses the router and +hands the turn to the planner. The planner can also load a skill itself, through +its `load_skill` tool, when a request matches the skill's description. + +A skill with `metadata: {agent: job_code}` in its frontmatter is written for +the job code agent. It can't be invoked or loaded by the planner; the planner +names it on a `call_job_code_agent` call, and its instructions are put ahead of +the planner's message, which overrides them where they conflict. + +Only `SKILL.md` is read. Add a file-read tool before bundling reference files. diff --git a/services/global_chat/skills/design/SKILL.md b/services/global_chat/skills/design/SKILL.md new file mode 100644 index 00000000..5e79f89e --- /dev/null +++ b/services/global_chat/skills/design/SKILL.md @@ -0,0 +1,52 @@ +--- +name: design +description: Work out with the user what a workflow should do, when they don't yet know its shape +--- + +The user wants to think the design through before anything is built. Treat it +as a consultation: suggest ideas and sketch options freely, but build only +when they're ready. + +Find out where they are and meet them there. Some don't yet know what could be +automated or how many workflows it takes; others have the shape and need help +with the details. Follow their lead on how deep to go. + +If the user struggles to specify what they want, start from people and process +rather than systems. Wherever the conversation starts, make sure the design +rests on the process it serves, and question requirements against how the +result will be used; the simpler design is often enough. The result is often a mix +of automated steps and steps people keep doing. + +Areas to draw on as the conversation needs them, not a list to work through: +- Value: what problem automation solves, what's done by hand today and what it + costs in time, and what happens if nothing changes. This tells you whether + and how much to automate. +- Process: who does each step, what depends on what, and what's redundant. +- Access: how each system can be reached (API, webhooks, file exports, direct + database), what's in the way (hosting, VPN, firewall, IP allowlisting), and + whether there's a test environment. Suggest getting credentials early so + access can be tested. If a system or its API is still being built or + configured, say it may be too early to integrate with it. +- Timing and volume: whether it really needs to be immediate, whether data + flows one way or both, and how much moves per run, which decides whether API + limits and paging matter. +- Ownership: who owns each system and its data, and who will run and maintain + the workflow, how technical they are, and who decides on changes later. +- Data: real sample records from each side, whether records move one to one + or are summarised, fields the target needs that users never see, and shared + lists (regions, services) and where they should live. +- Failure: who hears about it, and how a run is redone without duplicates. + +Ask a few questions at a time, most consequential first. When there's a real +choice, recommend one option and name the alternative. Prefer the simplest +design that meets the need, and say when an idea goes beyond what was asked. + +Flag details that rest on assumptions or documentation and still need +checking, and help the user see how to check them, without holding up the +design. If you're unsure how the platform behaves, say so rather than design +around a guess. Note open decisions and who can answer each rather than +filling them in. + +When the design is agreed, summarise it at the level of detail you reached, in +a form the user could share with the people who need to agree to it, with any +open decisions. Then offer to build it. diff --git a/services/global_chat/skills/diagnose/SKILL.md b/services/global_chat/skills/diagnose/SKILL.md new file mode 100644 index 00000000..17b42e60 --- /dev/null +++ b/services/global_chat/skills/diagnose/SKILL.md @@ -0,0 +1,28 @@ +--- +name: diagnose +description: Diagnose a failed workflow run from its logs, dataclips and workflow YAML +--- + +Diagnose why the run failed, or why it didn't do what the user expected. + +Work from the run logs and dataclips attached to this turn; if none are +attached, say what you need and stop. You can't rerun anything, so a cause the +evidence doesn't show is a hypothesis, and you should call it one. + +Find the error that started the failure and trace it into the code: which step +and operation raised it, and what data it was handling (often state a previous +step didn't write under that name, an adaptor overwriting `data`, or input +carried over from an earlier scheduled run). Point outside the code (a +credential, the connection, a target system's configuration) when the error +itself shows the cause is there. If the platform stopped the run (killed for +time, memory or state size, or lost), the cause is usually how much the code +holds or how long it runs rather than a wrong line, and missing logs aren't +evidence of a code bug. + +Look past the first failure too: read the later steps against the data they +would receive. Mention what you only suspect as something to watch. + +Tell the user what failed, why, and what showed you. If you're not certain, +give the likely causes and the one check that would tell them apart. Propose +the fix, along with anything you know is wrong in the later steps, and offer +to make it unless the user has already asked you to. diff --git a/services/global_chat/skills/qa-code/SKILL.md b/services/global_chat/skills/qa-code/SKILL.md new file mode 100644 index 00000000..85ccb676 --- /dev/null +++ b/services/global_chat/skills/qa-code/SKILL.md @@ -0,0 +1,39 @@ +--- +name: qa-code +description: A thorough review of one step's code, reporting what should change within it, or fixing it when asked. Use when the user considers the step done and hands it over for a full review, not for a quick check or one specific change. +metadata: + agent: job_code +--- + +Review this step's code and report what's wrong within it, fixing it only if +you're asked to make changes. Hold it to the spec and the user, and check it +against the steps it reads from and writes for. + +Cover: +- doing what the spec or user asks of this step; +- how it uses state: operations at the top level, never nested in callbacks; + callbacks that return state; state read lazily (`$` or a function) in + operation arguments; async work done through operations, not `async`/`await` + or promises; +- what it passes on: only plain JSON leaves the step, so helpers are defined in + the step rather than stored on state; only what the next step needs, with no + leftover or intermediate keys; no personal data logged or left on the final + state, which OpenFn doesn't scrub; +- what it takes in: incoming data validated before it's written, and a failing + system or malformed response handled rather than passed on; +- structure: several small operations that each do one thing rather than one + large `fn()`, and no `fn()` wrappers that add nothing; +- its adaptor: the adaptor's own functions rather than raw HTTP, used + correctly, and cursors set and advanced as intended; +- loops: state overwritten inside them, items or shared objects mutated while + iterating, `each()` used as intended; +- errors swallowed or never surfaced, and one bad record failing a whole batch + or disappearing silently, unless that's intended; +- work that runs away: unbounded retries, loops or calls; +- credentials written into code, values hardcoded that belong in + configuration, and anything left over from testing; +- comments that contradict the code. + +Report cleanup (duplication, dead code, unclear names, magic numbers) rather +than making it. Reply briefly with what you fixed, if anything, and what should +still change. diff --git a/services/global_chat/skills/qa/SKILL.md b/services/global_chat/skills/qa/SKILL.md new file mode 100644 index 00000000..2cababac --- /dev/null +++ b/services/global_chat/skills/qa/SKILL.md @@ -0,0 +1,76 @@ +--- +name: qa +description: Review a workflow against what it is meant to do and report what should change +--- + +Review the workflow against what it's meant to do, taken from the user, a spec +or ticket they've shared, or the workflow itself. If you can't tell, ask. With +a spec, say which parts are met, partly met or missing, including edge cases +it implies but doesn't state. + +Read first, then review every step, fixing what's wrong only if the user has +asked you to. + +1. Read every step's code yourself with `inspect_job_code` and follow the data + from the trigger to the last step, looking at what's below. Where steps + don't fit, decide which side is right, so each step's fix can be settled + before anything changes. + +2. Have the job code agent review every step in parallel with the `qa-code` + skill, which covers everything within a step. Your message carries what + only you know: the contract each step keeps with its neighbours (what it + reads and writes, under which names), which side of a mismatch it owns, + that the other steps are being handled at the same time so it covers only + its own, and what this conversation adds, such as what the spec asks of the + step, limits the user has set, or that the user wants the changes made. + Leave everything else in the step to the job code agent. Have the workflow + agent review the triggers, edges and adaptor choices too, telling it + whether the user wants the changes made. Workflow changes go through it. + +3. After making changes, read the changed steps again to confirm they now fit + together. + +How the steps fit together: +- the trigger is the right kind and enabled as intended; +- each step runs after everything it depends on; +- each step reads what the steps before it actually write, under the same + names, in the same shape, and as plain JSON, since anything else is lost + between steps; +- edges route data where it should go, so a failed or unmatched record can't + fall through; +- no two steps do the same work; +- each step uses its system's own adaptor where one exists rather than plain + HTTP; +- names other workflows rely on, such as collections, match exactly. + +How it behaves live, over many runs. What fails quietly matters more than what +fails loudly, and these usually need the user's decision: +- a re-run that creates duplicates; +- a cursor or paging that skips records; +- a lookup that assumes exactly one match; +- adaptors on `@latest`, which can change under a live workflow and break it. + The fix is to pin the version the workflow was tested on, which run logs + often show; if you can't tell, say the pinned version is untested. + +If dataclips are attached, check that the output means what the input meant. + +Tell the user what you changed, if anything, then what should still change: +- Group it by whether it must, should or could change, worst first, naming the + step and the operation. Anything that breaks, loses or exposes data, or + breaks OpenFn's job-writing conventions, is a must. +- Offer cleanup as could-change in a line or two, and do it only if the user + wants that kind of tidying. +- List a problem shared by several steps once. +- For a suggested change, show only the lines that change, with the + recommended fix and any alternative in a line. +- Skip style nits a formatter would catch and speculative performance + concerns. +- Say when a problem is inferred from the code rather than seen in the data. +- If nothing needs changing, say so. On a follow-up review, say which earlier + issues are fixed and which aren't. + +Then offer to make what should still change. When making changes, propose first any that would change behaviour beyond the +problem or need the user's decision, and once the user agrees to changes, +give each step the ones agreed for it, without the `qa-code` skill, rather +than reviewing it again. If you changed anything, remind the user to test the +workflow again before relying on it. diff --git a/services/global_chat/subagent_caller.py b/services/global_chat/subagent_caller.py index dc446439..547cc82d 100644 --- a/services/global_chat/subagent_caller.py +++ b/services/global_chat/subagent_caller.py @@ -12,6 +12,7 @@ sys.path.append(str(Path(__file__).parent.parent.parent)) from langfuse import observe +from global_chat.skill_registry import JOB_AGENT_SKILLS, job_agent_skills from util import create_logger, ApolloError, attachments_to_context, select_attachments from yaml_utils import find_job_in_yaml @@ -106,6 +107,12 @@ def call_job_agent( if not user_message: raise ApolloError(400, "message is required") + skill = JOB_AGENT_SKILLS.get(tool_input.get("skill")) + if skill: + user_message = f"{skill.as_subagent_preamble()}\n\n{user_message}" + elif tool_input.get("skill"): + logger.warning(f"job_agent: ignoring unknown skill '{tool_input['skill']}'") + job_context = {} job_key = tool_input.get("job_key") @@ -141,6 +148,7 @@ def call_job_agent( # the block and the inspect_job_code tool. "subagent": True, "workflow_yaml": workflow_yaml, + "skills": job_agent_skills(attached=skill.name if skill else None), } try: @@ -152,6 +160,9 @@ def call_job_agent( logger.info(f"job_agent response: {response_preview}") result["_call_metadata"] = {"subagent": "job_agent", "job_key": job_key} + if skill: + meta = result.setdefault("meta", {}) + meta["skills"] = [skill.name, *meta.get("skills", [])] return result diff --git a/services/global_chat/tests/acceptance/skills/test_design_continues_over_turns.md b/services/global_chat/tests/acceptance/skills/test_design_continues_over_turns.md new file mode 100644 index 00000000..3f9b5576 --- /dev/null +++ b/services/global_chat/tests/acceptance/skills/test_design_continues_over_turns.md @@ -0,0 +1,124 @@ +--- +id: global-chat.skills.design-continues-over-turns +service: global_chat +judges: [general] +--- + +# notes + +The user started /design on the previous turn. Its instructions are in that +turn of the history, exactly as Apollo returned it, and must keep applying now +that the user has answered. The planner's own default is to build a workflow as +soon as the shape is guessable; /design overrides that until the user agrees on +a design. Judge the conversation, not the wording: there are many good ways to +continue a consultation. + +# quality_criteria + +- The reply continues the design conversation, asking follow-up questions or offering options, and does not build or change a workflow yet. +- It engages with the process the user described, a weekly manual copy of Kobo form data into DHIS2, rather than asking generic questions it could have asked before. +- It asks only a few questions at a time, rather than a long questionnaire. + +# settings + +## page + +workflows/new + +## meta.session_id + +sess-skills-design-continues-0001 + +# history + +## turn + +### role + +user + +### content + +[pg:workflow] +The user wants to think the design through before anything is built. Treat it +as a consultation: suggest ideas and sketch options freely, but build only +when they're ready. + +Find out where they are and meet them there. Some don't yet know what could be +automated or how many workflows it takes; others have the shape and need help +with the details. Follow their lead on how deep to go. + +If the user struggles to specify what they want, start from people and process +rather than systems. Wherever the conversation starts, make sure the design +rests on the process it serves, and question requirements against how the +result will be used; the simpler design is often enough. The result is often a mix +of automated steps and steps people keep doing. + +Areas to draw on as the conversation needs them, not a list to work through: +- Value: what problem automation solves, what's done by hand today and what it + costs in time, and what happens if nothing changes. This tells you whether + and how much to automate. +- Process: who does each step, what depends on what, and what's redundant. +- Access: how each system can be reached (API, webhooks, file exports, direct + database), what's in the way (hosting, VPN, firewall, IP allowlisting), and + whether there's a test environment. Suggest getting credentials early so + access can be tested. If a system or its API is still being built or + configured, say it may be too early to integrate with it. +- Timing and volume: whether it really needs to be immediate, whether data + flows one way or both, and how much moves per run, which decides whether API + limits and paging matter. +- Ownership: who owns each system and its data, and who will run and maintain + the workflow, how technical they are, and who decides on changes later. +- Data: real sample records from each side, whether records move one to one + or are summarised, fields the target needs that users never see, and shared + lists (regions, services) and where they should live. +- Failure: who hears about it, and how a run is redone without duplicates. + +Ask a few questions at a time, most consequential first. When there's a real +choice, recommend one option and name the alternative. Prefer the simplest +design that meets the need, and say when an idea goes beyond what was asked. + +Flag details that rest on assumptions or documentation and still need +checking, and help the user see how to check them, without holding up the +design. If you're unsure how the platform behaves, say so rather than design +around a guess. Note open decisions and who can answer each rather than +filling them in. + +When the design is agreed, summarise it at the level of detail you reached, in +a form the user could share with the people who need to agree to it, with any +open decisions. Then offer to build it. + + +The user invoked /design. + +I want to automate something with our clinic data but I'm not sure what yet + +## turn + +### role + +assistant + +### content + +Happy to help you figure this out. Let's start from how your clinic actually works, not the tech. + +A few questions to get oriented: + +1. **What's painful right now?** Where does someone on your team spend time copying data between systems, re-typing things, chasing reports, or reconciling records by hand? That's usually where automation pays off first. + +2. **What systems hold your clinic data?** For example an EMR/EHR (OpenMRS, DHIS2, CommCare, Bahmni, a custom system?), a spreadsheet, a lab system, a billing tool, an SMS service? Even a rough list helps. + +3. **Who are you reporting to or sharing data with?** Many clinics have to send aggregate numbers to a ministry/DHIS2, sync patient records somewhere, or send reminders to patients. Is anything like that part of your world? + +You don't need precise answers — just tell me what a typical frustrating task looks like, and we'll trace it back to what could be automated. + +# turn + +## role + +user + +## content + +Our nurses fill in a KoboToolbox form for every patient visit, and every Friday someone copies the totals into DHIS2 by hand. It takes most of the afternoon. diff --git a/services/global_chat/tests/acceptance/skills/test_qa_adaptor_choice.md b/services/global_chat/tests/acceptance/skills/test_qa_adaptor_choice.md new file mode 100644 index 00000000..d7beb909 --- /dev/null +++ b/services/global_chat/tests/acceptance/skills/test_qa_adaptor_choice.md @@ -0,0 +1,90 @@ +--- +id: global-chat.skills.qa-adaptor-choice +service: global_chat +judges: [general] +--- + +# notes + +The user runs /qa on a workflow whose second step posts to DHIS2 with the plain +HTTP adaptor, although DHIS2 has its own adaptor. The planner has no adaptor +list of its own, so this tests that /qa's review by the workflow agent catches +the choice. /qa reports and changes nothing until the user asks. + +# quality_criteria + +- The reply recommends the DHIS2 adaptor for the step that posts to DHIS2, rather than plain HTTP. +- No job code or workflow YAML is changed; the reply proposes changes rather than making them. + +# settings + +## page + +workflows/visit-sync + +## skill + +```json +{"name": "qa"} +``` + +## workflow_yaml + +```yaml +name: visit-sync +jobs: + fetch-visits: + id: job-fetch-visits-id + name: Fetch visits + adaptor: "@openfn/language-http@7.0.0" + body: | + get('https://clinic.example.org/api/visits'); + fn(state => ({ ...state, visits: state.data.visits })); + send-to-dhis2: + id: job-send-to-dhis2-id + name: Send to DHIS2 + adaptor: "@openfn/language-http@7.0.0" + body: | + each( + $.visits, + post('https://dhis2.example.org/api/events', state => ({ + program: 'eBAghkXzQGu', + orgUnit: state.data.facilityId, + eventDate: state.data.date, + status: 'COMPLETED', + })) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "0 3 * * *" + enabled: true +edges: + cron->fetch-visits: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-visits + condition_type: always + enabled: true + fetch-visits->send-to-dhis2: + id: edge-fetch-send + source_job: fetch-visits + target_job: send-to-dhis2 + condition_type: on_job_success + enabled: true +``` + +## meta.session_id + +sess-skills-qa-adaptor-choice-0001 + +# turn + +## role + +user + +## content + +/qa can you check this before we turn it on for all clinics? diff --git a/services/global_chat/tests/acceptance/skills/test_qa_fixes_when_asked.md b/services/global_chat/tests/acceptance/skills/test_qa_fixes_when_asked.md new file mode 100644 index 00000000..c75c0683 --- /dev/null +++ b/services/global_chat/tests/acceptance/skills/test_qa_fixes_when_asked.md @@ -0,0 +1,114 @@ +--- +id: global-chat.skills.qa-fixes-when-asked +service: global_chat +judges: [general] +--- + +# notes + +Same workflow as the qa-whole-workflow test, but the user asks for fixes up +front, so /qa reviews and fixes in one pass. The planner hands each step to the +job code agent with its qa-code skill, says the user wants the changes made, +and fits the steps together itself. Judge the outcome: which side of the +mismatch changes doesn't matter as long as the two steps agree, and the report +can take any reasonable shape. + +# quality_criteria + +- After the changes, the upload step reads the patient list from the same key the fetch step writes it to, so the crash is fixed. +- The existing comment in the fetch step is still there and unchanged. +- Each `@latest` adaptor is pinned to 7.0.0, the version the run log shows, or the reply recommends pinning it to the tested version. +- The reply says what was fixed and what should still change, with the most serious problems first, and reminds the user to test the workflow again. + +# settings + +## page + +workflows/patient-sync + +## skill + +```json +{"name": "qa"} +``` + +## workflow_yaml + +```yaml +name: patient-sync +jobs: + fetch-patients: + id: job-fetch-patients-id + name: Fetch patients + adaptor: "@openfn/language-http@latest" + body: | + get('https://example.org/api/patients'); + // The API nests patients under results + fn(state => ({ ...state, patients: state.data.results })); + upload-patients: + id: job-upload-patients-id + name: Upload patients + adaptor: "@openfn/language-http@latest" + body: | + each( + $.patient_list, + post('https://target.example.org/api/people', state => ({ + name: state.data.name, + sex: state.data.gender, + })) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "0 2 * * *" + enabled: true +edges: + cron->fetch-patients: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-patients + condition_type: always + enabled: true + fetch-patients->upload-patients: + id: edge-fetch-upload + source_job: fetch-patients + target_job: upload-patients + condition_type: on_job_success + enabled: true +``` + +## attachments + +```json +[ + { + "type": "log", + "content": [ + "[CLI] Versions: node 22, worker 1.x, @openfn/language-http 7.0.0", + "[R/T] Starting step Fetch patients", + "[HTTP] GET https://example.org/api/patients - 200 in 312ms", + "[R/T] Completed step Fetch patients in 401ms", + "[R/T] Starting step Upload patients", + "[R/T] Error in runtime execution!", + "[R/T] TypeError: Cannot read properties of undefined (reading 'length')", + " at each (@openfn/language-common)", + "[R/T] Failed step Upload patients after 12ms" + ] + } +] +``` + +## meta.session_id + +sess-skills-qa-fixes-when-asked-0001 + +# turn + +## role + +user + +## content + +/qa please review this and fix what you find before it goes live, but leave any comments alone diff --git a/services/global_chat/tests/acceptance/skills/test_qa_makes_agreed_changes.md b/services/global_chat/tests/acceptance/skills/test_qa_makes_agreed_changes.md new file mode 100644 index 00000000..c44da126 --- /dev/null +++ b/services/global_chat/tests/acceptance/skills/test_qa_makes_agreed_changes.md @@ -0,0 +1,209 @@ +--- +id: global-chat.skills.qa-makes-agreed-changes +service: global_chat +judges: [general] +--- + +# notes + +The turn after the qa-whole-workflow test: /qa reported what should change and +offered to make it, and the user now agrees to some of it. The /qa +instructions are in the first turn of the history, as Apollo returned them. +The planner should make only the agreed changes, giving each step its listed +change rather than running the full review again, then check the steps fit. +Judge the outcome: which side of the key mismatch changes doesn't matter as +long as the two steps agree. + +# quality_criteria + +- After the changes, the upload step reads the patient list from the same key the fetch step writes it to. +- Both adaptors are pinned to `@openfn/language-http@7.0.0`. +- The existing comment in the fetch step is still there and unchanged. +- Nothing else in the code changes: no validation guards, URL changes or duplicate handling the user didn't agree to. +- The reply says briefly what was changed and reminds the user to test the workflow again, without repeating the full review. + +# settings + +## page + +workflows/patient-sync + +## workflow_yaml + +```yaml +name: patient-sync +jobs: + fetch-patients: + id: job-fetch-patients-id + name: Fetch patients + adaptor: "@openfn/language-http@latest" + body: | + get('https://example.org/api/patients'); + // The API nests patients under results + fn(state => ({ ...state, patients: state.data.results })); + upload-patients: + id: job-upload-patients-id + name: Upload patients + adaptor: "@openfn/language-http@latest" + body: | + each( + $.patient_list, + post('https://target.example.org/api/people', state => ({ + name: state.data.name, + sex: state.data.gender, + })) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "0 2 * * *" + enabled: true +edges: + cron->fetch-patients: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-patients + condition_type: always + enabled: true + fetch-patients->upload-patients: + id: edge-fetch-upload + source_job: fetch-patients + target_job: upload-patients + condition_type: on_job_success + enabled: true +``` + +## meta.session_id + +sess-skills-qa-makes-agreed-changes-0001 + +# history + +## turn + +### role + +user + +### content + +[pg:workflow] +Review the workflow against what it's meant to do, taken from the user, a spec +or ticket they've shared, or the workflow itself. If you can't tell, ask. With +a spec, say which parts are met, partly met or missing, including edge cases +it implies but doesn't state. + +Read first, then review every step, fixing what's wrong only if the user has +asked you to. + +1. Read every step's code yourself with `inspect_job_code` and follow the data + from the trigger to the last step, looking at what's below. Where steps + don't fit, decide which side is right, so each step's fix can be settled + before anything changes. + +2. Have the job code agent review every step in parallel with the `qa-code` + skill, which covers everything within a step. Your message carries what + only you know: the contract each step keeps with its neighbours (what it + reads and writes, under which names), which side of a mismatch it owns, + that the other steps are being handled at the same time so it covers only + its own, and what this conversation adds, such as what the spec asks of the + step, limits the user has set, or that the user wants the changes made. + Leave everything else in the step to the job code agent. Have the workflow + agent review the triggers, edges and adaptor choices too, telling it + whether the user wants the changes made. Workflow changes go through it. + +3. After making changes, read the changed steps again to confirm they now fit + together. + +How the steps fit together: +- the trigger is the right kind and enabled as intended; +- each step runs after everything it depends on; +- each step reads what the steps before it actually write, under the same + names, in the same shape, and as plain JSON, since anything else is lost + between steps; +- edges route data where it should go, so a failed or unmatched record can't + fall through; +- no two steps do the same work; +- each step uses its system's own adaptor where one exists rather than plain + HTTP; +- names other workflows rely on, such as collections, match exactly. + +How it behaves live, over many runs. What fails quietly matters more than what +fails loudly, and these usually need the user's decision: +- a re-run that creates duplicates; +- a cursor or paging that skips records; +- a lookup that assumes exactly one match; +- adaptors on `@latest`, which can change under a live workflow and break it. + The fix is to pin the version the workflow was tested on, which run logs + often show; if you can't tell, say the pinned version is untested. + +If dataclips are attached, check that the output means what the input meant. + +Tell the user what you changed, if anything, then what should still change: +- Group it by whether it must, should or could change, worst first, naming the + step and the operation. Anything that breaks, loses or exposes data, or + breaks OpenFn's job-writing conventions, is a must. +- Offer cleanup as could-change in a line or two, and do it only if the user + wants that kind of tidying. +- List a problem shared by several steps once. +- For a suggested change, show only the lines that change, with the + recommended fix and any alternative in a line. +- Skip style nits a formatter would catch and speculative performance + concerns. +- Say when a problem is inferred from the code rather than seen in the data. +- If nothing needs changing, say so. On a follow-up review, say which earlier + issues are fixed and which aren't. + +Then offer to make what should still change. When making changes, propose first any that would change behaviour beyond the +problem or need the user's decision, and once the user agrees to changes, +give each step the ones agreed for it, without the `qa-code` skill, rather +than reviewing it again. If you changed anything, remind the user to test the +workflow again before relying on it. + + +Follow these instructions for the request below. + +please review this before it goes live, and leave any comments alone + +## turn + +### role + +assistant + +### content + +Here's my review of **patient-sync** before go-live. I haven't changed anything yet. + +## Must change (breaks or loses data) + +- **`upload-patients` reads the wrong key — this is the live failure.** The step iterates `$.patient_list`, but `fetch-patients` writes the array to `state.patients`. `each` calls `.length` on an undefined source, which is exactly the `Cannot read properties of undefined (reading 'length')` error in your log. Fix: iterate `state.patients`. + +- **`fetch-patients` may be writing the array to the wrong path.** It sets `patients: state.data.results`, trusting the comment "API nests patients under results." That shape is unverified — if the real response isn't nested under `results`, `state.patients` is undefined and `upload-patients` fails even after the key fix. The 200 response in the log should be checked against the actual array path. (Inferred from code + log, not confirmed against the response body.) + +## Should change (reliability) + +- **Daily re-runs will create duplicate records** (both steps, cron runs nightly). `post` is non-idempotent, so every night re-POSTs every patient as a new person. This needs your decision — ideally an upsert/`put` keyed on a stable patient ID, or an existence check before posting. Biggest go-live risk after the two bugs above. + +- **No validation in either step.** `fetch-patients` should guarantee an array to its consumer (fail loudly here rather than pushing `undefined` downstream); `upload-patients` posts patients with missing `name`/`gender` as-is. Also worth confirming the fetched objects actually use `name`/`gender`. + +- **Adaptors pinned to `@latest`** (both steps). Your log shows the workflow was tested on `@openfn/language-http@7.0.0` — pinning to that avoids a live break when the adaptor changes. + +## Could change (cleanup) + +- **Hardcoded URLs** in both steps. Move the base URL into the credential (`baseUrl`) and pass just the path — keeps test/prod swappable. + +Your existing comments will be left in place as you asked. + +Want me to apply the fixes? I'd suggest doing the two **must** fixes plus pinning to `7.0.0` now. The duplicate-prevention approach needs a decision from you first — do you have a stable patient ID and does the target API support upsert? Once changes are made, test the workflow again before relying on it. + +# turn + +## role + +user + +## content + +Yes, fix the key and pin both to 7.0.0. We don't have a stable patient ID yet, so leave the duplicates for now. diff --git a/services/global_chat/tests/acceptance/skills/test_qa_whole_workflow.md b/services/global_chat/tests/acceptance/skills/test_qa_whole_workflow.md new file mode 100644 index 00000000..17895b26 --- /dev/null +++ b/services/global_chat/tests/acceptance/skills/test_qa_whole_workflow.md @@ -0,0 +1,114 @@ +--- +id: global-chat.skills.qa-whole-workflow +service: global_chat +judges: [general] +--- + +# notes + +The user runs /qa on a two-step workflow before it goes live. The upload step +reads `$.patient_list`, but the fetch step writes `state.patients`, which is +why the last run failed. Both steps use `@latest`; the run log shows the +version they were tested on. /qa reviews and reports; it changes nothing until +the user asks. Judge the outcome: which side of the mismatch the fix changes +doesn't matter, and the report can take any reasonable shape. + +# quality_criteria + +- No job code or workflow YAML is changed; the reply proposes changes rather than making them. +- The reply names the key mismatch between the two steps as the cause of the crash and shows the fix. +- The reply recommends pinning each `@latest` adaptor to 7.0.0, the version the run log shows. +- The most serious problems come first, and the reply ends by offering to make the changes, without dwelling on not having made them. + +# settings + +## page + +workflows/patient-sync + +## skill + +```json +{"name": "qa"} +``` + +## workflow_yaml + +```yaml +name: patient-sync +jobs: + fetch-patients: + id: job-fetch-patients-id + name: Fetch patients + adaptor: "@openfn/language-http@latest" + body: | + get('https://example.org/api/patients'); + // The API nests patients under results + fn(state => ({ ...state, patients: state.data.results })); + upload-patients: + id: job-upload-patients-id + name: Upload patients + adaptor: "@openfn/language-http@latest" + body: | + each( + $.patient_list, + post('https://target.example.org/api/people', state => ({ + name: state.data.name, + sex: state.data.gender, + })) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "0 2 * * *" + enabled: true +edges: + cron->fetch-patients: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-patients + condition_type: always + enabled: true + fetch-patients->upload-patients: + id: edge-fetch-upload + source_job: fetch-patients + target_job: upload-patients + condition_type: on_job_success + enabled: true +``` + +## attachments + +```json +[ + { + "type": "log", + "content": [ + "[CLI] Versions: node 22, worker 1.x, @openfn/language-http 7.0.0", + "[R/T] Starting step Fetch patients", + "[HTTP] GET https://example.org/api/patients - 200 in 312ms", + "[R/T] Completed step Fetch patients in 401ms", + "[R/T] Starting step Upload patients", + "[R/T] Error in runtime execution!", + "[R/T] TypeError: Cannot read properties of undefined (reading 'length')", + " at each (@openfn/language-common)", + "[R/T] Failed step Upload patients after 12ms" + ] + } +] +``` + +## meta.session_id + +sess-skills-qa-whole-workflow-0001 + +# turn + +## role + +user + +## content + +/qa please review this before it goes live, and leave any comments alone diff --git a/services/global_chat/tests/acceptance/skills/test_specific_change_stays_specific.md b/services/global_chat/tests/acceptance/skills/test_specific_change_stays_specific.md new file mode 100644 index 00000000..bb6a3ecd --- /dev/null +++ b/services/global_chat/tests/acceptance/skills/test_specific_change_stays_specific.md @@ -0,0 +1,107 @@ +--- +id: global-chat.skills.specific-change-stays-specific +service: global_chat +judges: [general] +--- + +# notes + +Same step as the review test, but the user asks for one specific change. This +is the negative case: the job agent should make the change, not run a full +review. Mentioning one serious problem it noticed is fine, briefly; a full +review of the step, or fixing things the user didn't ask about, is not. + +# quality_criteria + +- The upload step now maps the field to `gender` instead of `sex`. +- No other part of the code is changed. Judge the code itself: the workflow YAML may come back re-serialised (quoting, block style), which doesn't count as a change. +- The reply stays focused on the requested change rather than giving a full review of the step's other problems; at most it briefly mentions one. + +# settings + +## page + +workflows/patient-sync/upload-patients + +## workflow_yaml + +```yaml +name: patient-sync +jobs: + fetch-patients: + id: job-fetch-patients-id + name: Fetch patients + adaptor: "@openfn/language-http@7.0.0" + body: | + get('https://example.org/api/patients'); + fn(state => ({ ...state, patients: state.data.results })); + upload-patients: + id: job-upload-patients-id + name: Upload patients + adaptor: "@openfn/language-http@7.0.0" + body: | + each( + $.patients, + post('https://target.example.org/api/people', state => { + console.log('Uploading patient', state.data.name, state.data.phone); + return { + name: state.data.name, + sex: state.data.gender, + registered: '2024-01-01', + }; + }).catch((error, state) => state) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "0 2 * * *" + enabled: true +edges: + cron->fetch-patients: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-patients + condition_type: always + enabled: true + fetch-patients->upload-patients: + id: edge-fetch-upload + source_job: fetch-patients + target_job: upload-patients + condition_type: on_job_success + enabled: true +``` + +## attachments + +```json +[ + { + "type": "log", + "content": [ + "[CLI] Versions: node 22, worker 1.x, @openfn/language-http 7.0.0", + "[R/T] Starting step Fetch patients", + "[HTTP] GET https://example.org/api/patients - 200 in 280ms", + "[R/T] Completed step Fetch patients in 350ms", + "[R/T] Starting step Upload patients", + "[JOB] Uploading patient ", + "[HTTP] POST https://target.example.org/api/people - 201 in 190ms", + "[R/T] Completed step Upload patients in 410ms" + ] + } +] +``` + +## meta.session_id + +sess-skills-specific-change-0001 + +# turn + +## role + +user + +## content + +Rename the sex field to gender in the upload diff --git a/services/global_chat/tests/acceptance/skills/test_step_review_when_done.md b/services/global_chat/tests/acceptance/skills/test_step_review_when_done.md new file mode 100644 index 00000000..a5c8c1bb --- /dev/null +++ b/services/global_chat/tests/acceptance/skills/test_step_review_when_done.md @@ -0,0 +1,111 @@ +--- +id: global-chat.skills.step-review-when-done +service: global_chat +judges: [general] +--- + +# notes + +The user considers the upload step finished and hands it over for review, from +the step's own page, so the request goes straight to the job code agent. Its +last run succeeded, so nothing visibly fails; the step has problems only a +review would catch. The job agent should pick up its qa-code skill and review +the step properly. A good reply may fix some problems and only flag others; +either is fine as long as each problem is dealt with. It may also check the +upstream step. + +# quality_criteria + +- The reply deals with patient personal data being written to the run logs (the console.log of names and phone numbers), by removing it or clearly flagging it. +- The reply deals with failures being silently swallowed (the `.catch` that just returns state), by fixing it or clearly flagging it. +- The reply deals with the hardcoded `registered: '2024-01-01'` date, by fixing it or flagging it as a value that shouldn't be hardcoded. +- Anything changed in the code stays within the upload step and is related to a problem the reply names. + +# settings + +## page + +workflows/patient-sync/upload-patients + +## workflow_yaml + +```yaml +name: patient-sync +jobs: + fetch-patients: + id: job-fetch-patients-id + name: Fetch patients + adaptor: "@openfn/language-http@7.0.0" + body: | + get('https://example.org/api/patients'); + fn(state => ({ ...state, patients: state.data.results })); + upload-patients: + id: job-upload-patients-id + name: Upload patients + adaptor: "@openfn/language-http@7.0.0" + body: | + each( + $.patients, + post('https://target.example.org/api/people', state => { + console.log('Uploading patient', state.data.name, state.data.phone); + return { + name: state.data.name, + sex: state.data.gender, + registered: '2024-01-01', + }; + }).catch((error, state) => state) + ); +triggers: + cron: + id: trigger-cron-id + type: cron + cron_expression: "0 2 * * *" + enabled: true +edges: + cron->fetch-patients: + id: edge-cron-fetch + source_trigger: cron + target_job: fetch-patients + condition_type: always + enabled: true + fetch-patients->upload-patients: + id: edge-fetch-upload + source_job: fetch-patients + target_job: upload-patients + condition_type: on_job_success + enabled: true +``` + +## attachments + +```json +[ + { + "type": "log", + "content": [ + "[CLI] Versions: node 22, worker 1.x, @openfn/language-http 7.0.0", + "[R/T] Starting step Fetch patients", + "[HTTP] GET https://example.org/api/patients - 200 in 280ms", + "[R/T] Completed step Fetch patients in 350ms", + "[R/T] Starting step Upload patients", + "[JOB] Uploading patient ", + "[HTTP] POST https://target.example.org/api/people - 201 in 190ms", + "[R/T] Completed step Upload patients in 410ms" + ] + } +] +``` + +## meta.session_id + +sess-skills-step-review-when-done-0001 + +# turn + +## role + +user + +## content + +I think this step is done now. Can you review it before it goes live? diff --git a/services/global_chat/tests/unit/test_planner.py b/services/global_chat/tests/unit/test_planner.py index 7908d0e8..0f08c99c 100644 --- a/services/global_chat/tests/unit/test_planner.py +++ b/services/global_chat/tests/unit/test_planner.py @@ -42,6 +42,8 @@ def make_planner() -> PlannerAgent: planner.subagent_results = [] planner._segments = [] planner._attachments = [] + planner._skill = None + planner._loaded_skills = {} planner.api_key = "test-key" planner._user = None planner._metrics_opt_in = None @@ -81,6 +83,9 @@ def send_thinking(self, status: object = None, *_args: object, **_kwargs: object def send_changes(self, *_args: object, **_kwargs: object) -> None: pass + def end_stream(self, *_args: object, **_kwargs: object) -> None: + pass + def send_status( self, content: str, diff --git a/services/global_chat/tests/unit/test_skills.py b/services/global_chat/tests/unit/test_skills.py new file mode 100644 index 00000000..f4992899 --- /dev/null +++ b/services/global_chat/tests/unit/test_skills.py @@ -0,0 +1,457 @@ +"""Unit tests for skill invocation. + +A slash command states the intent the router would otherwise guess, so an +invoked skill skips the routing call and goes to the planner. The skill's +instructions are kept in the returned history, as in a standard agent +transcript, so they still apply on later turns, and those turns stay with the +planner too. + +Apollo never parses commands out of free text — the client recognises the +command and names it in the payload — so a message that merely mentions a +slash command routes normally. +""" + +from unittest.mock import patch + +import pytest +from global_chat.global_chat import Payload +from global_chat.planner import PlannerResult +from global_chat.router import RouterAgent, RouterDecision, RouterResult +from global_chat.skill_registry import JOB_AGENT_SKILLS, SKILLS, get_skill, has_skill, strip_invocation +from global_chat.subagent_caller import call_job_agent +from global_chat.tools.tool_definitions import CALL_JOB_CODE_AGENT_TOOL, LOAD_SKILL_TOOL, TOOL_DEFINITIONS +from util import ApolloError + +from streaming_util import STATUS_NEW_WORKFLOW, STATUS_PLANNING + +from .test_attachments import job_chat_result +from .test_planner import ( + WORKFLOW_YAML, + FakeResponse, + FakeText, + FakeToolUse, + StubStreamManager, + empty_usage, + make_planner, +) +from .test_router import make_router + + +# --- the registry --------------------------------------------------------- + + +def test_the_standard_skills_are_loaded() -> None: + assert {"design", "diagnose", "qa"} <= set(SKILLS) + + +def test_every_skill_declares_the_name_it_is_invoked_by() -> None: + """A frontmatter name that disagrees with its folder is unreachable.""" + for name, skill in SKILLS.items(): + assert skill.name == name + assert skill.description + assert skill.body + + +def test_an_unknown_skill_is_rejected_rather_than_ignored() -> None: + with pytest.raises(ApolloError) as excinfo: + get_skill("sudo") + + assert excinfo.value.code == 400 + assert excinfo.value.type == "UNKNOWN_SKILL" + assert "diagnose" in excinfo.value.details["available"] + + +# --- the payload field ---------------------------------------------------- + + +def test_a_named_skill_arrives_resolved() -> None: + payload = Payload.from_dict({"content": "/qa", "skill": {"name": "qa"}}) + + assert payload.skill.body == get_skill("qa").body + + +def test_a_skill_that_is_not_an_object_with_a_name_is_rejected() -> None: + with pytest.raises(ApolloError) as excinfo: + Payload.from_dict({"content": "hi", "skill": "qa"}) + + assert excinfo.value.code == 400 + + +def test_no_skill_field_is_the_unchanged_payload() -> None: + assert Payload.from_dict({"content": "hi"}).skill is None + + +# --- stripping the command token ------------------------------------------ + + +def test_the_command_token_goes_and_the_request_stays() -> None: + assert strip_invocation("/diagnose why did this fail?", "diagnose") == "why did this fail?" + + +def test_a_bare_command_leaves_no_request() -> None: + assert strip_invocation("/qa", "qa") == "" + + +def test_a_command_token_is_only_stripped_at_the_start() -> None: + assert strip_invocation("tell me what /qa does", "qa") == "tell me what /qa does" + + +def test_a_longer_word_starting_with_the_name_is_not_the_command() -> None: + assert strip_invocation("/qa-checklist please", "qa") == "/qa-checklist please" + + +# --- the bypass ----------------------------------------------------------- + + +def stub_planner(captured: dict) -> type: + """A PlannerAgent that records the call instead of making one.""" + + class StubPlanner: + def __init__(self, *_args: object, **_kwargs: object) -> None: + pass + + def run(self, **kwargs: object) -> PlannerResult: + captured.update(kwargs) + return PlannerResult( + response="done", + response_segments=[], + attachments=[], + history=[], + usage=empty_usage(), + meta={"agents": ["router", "planner"], "planner_iterations": 0}, + ) + + return StubPlanner + + +def invoke_skill(name: str, content: str) -> tuple[dict, RouterResult]: + router = make_router() + router.config_loader = None + captured: dict = {} + + with patch("global_chat.planner.PlannerAgent", stub_planner(captured)), \ + patch.object(RouterAgent, "_make_routing_decision") as decision: + result = router.route_and_execute( + content=content, + workflow_yaml=None, + page=None, + history=[], + stream=False, + skill=get_skill(name), + ) + + assert decision.call_count == 0 + return captured, result + + +def test_an_invoked_skill_never_pays_for_a_routing_call() -> None: + captured, result = invoke_skill("diagnose", "/diagnose why did this fail?") + + assert captured["skill"].name == "diagnose" + assert result.meta["skill"] == "diagnose" + # The router did not run, so it has neither a place in the path nor a + # confidence to report. + assert "router" not in result.meta["agents"] + assert "router_confidence" not in result.meta + + +def test_the_planner_is_asked_what_the_user_asked_not_the_command() -> None: + captured, _ = invoke_skill("diagnose", "/diagnose why did this fail?") + + assert captured["content"] == "why did this fail?" + + +def test_a_message_that_only_mentions_a_command_is_routed_normally() -> None: + router = make_router() + router.config_loader = None + captured: dict = {} + + with patch("global_chat.planner.PlannerAgent", stub_planner(captured)), \ + patch.object( + RouterAgent, + "_make_routing_decision", + return_value=RouterDecision(destination="planner", confidence=4), + ) as decision: + result = router.route_and_execute( + content="/qa looks odd on this workflow", + workflow_yaml=None, + page=None, + history=[], + stream=False, + ) + + assert decision.call_count == 1 + assert captured["skill"] is None + assert captured["content"] == "/qa looks odd on this workflow" + assert result.meta["router_confidence"] == 4 + + +# --- injection into the turn ---------------------------------------------- + + +def test_the_skill_instructions_lead_the_turn_the_model_sees() -> None: + planner = make_planner() + planner._skill = get_skill("qa") + + user_content = planner._build_user_content("check this", None) + + assert user_content.startswith('') + assert get_skill("qa").body in user_content + assert "check this" in user_content + + +def test_a_bare_invocation_still_carries_the_instructions() -> None: + planner = make_planner() + planner._skill = get_skill("qa") + + assert get_skill("qa").body in planner._build_user_content("", None) + + +def run_planner(skill_name: str | None = None, tool_calls: list | None = None, **kwargs: object) -> object: + """One planner turn: the given tool calls, if any, then the answer.""" + planner = make_planner() + planner.model = "test-model" + planner.max_tool_calls = 5 + responses = [FakeResponse("tool_use", [call]) for call in tool_calls or []] + responses.append(FakeResponse("end_turn", [FakeText("here is the review")])) + + with patch.object(planner, "_call_api", side_effect=responses), \ + patch.object(planner, "_build_system_prompt", return_value="sys"): + return planner.run( + content="check this", + workflow_yaml=kwargs.get("workflow_yaml"), + page=kwargs.get("page"), + history=[], + stream=False, + skill=get_skill(skill_name) if skill_name else None, + ) + + +def test_an_invoked_skill_is_kept_in_history() -> None: + result = run_planner("qa") + + user_turn = result.history[0]["content"] + assert user_turn == f"[pg:workflow] {get_skill('qa').as_preamble()}\n\ncheck this" + assert has_skill(result.history) + + +def test_a_skill_the_planner_loaded_is_kept_in_history() -> None: + result = run_planner(tool_calls=[FakeToolUse("load_skill", {"name": "qa"})]) + + assert result.history[0]["content"] == f"[pg:workflow] check this\n\n{get_skill('qa').as_block()}" + + +def test_a_turn_without_a_skill_keeps_only_the_message() -> None: + result = run_planner() + + assert [turn["content"] for turn in result.history] == ["[pg:workflow] check this", "here is the review"] + assert not has_skill(result.history) + + +def test_a_planner_turn_on_a_step_records_the_page_like_job_chat() -> None: + yaml = 'jobs:\n fetch:\n name: Fetch Data\n adaptor: "@openfn/language-http@6.0.0"\n body: ""\n' + + result = run_planner(workflow_yaml=yaml, page="workflows/wf/fetch") + + assert result.history[0]["content"] == "[pg:job_code/Fetch Data/http@6.0.0] check this" + + +def test_a_follow_up_to_a_skill_stays_with_the_planner() -> None: + router = make_router() + router.config_loader = None + captured: dict = {} + history = [ + {"role": "user", "content": f"{get_skill('qa').as_preamble()}\n\ncheck this"}, + {"role": "assistant", "content": "which step?"}, + ] + + with patch("global_chat.planner.PlannerAgent", stub_planner(captured)), \ + patch.object(RouterAgent, "_make_routing_decision") as decision: + result = router.route_and_execute( + content="/qa the first one", + workflow_yaml=None, + page=None, + history=history, + stream=False, + ) + + assert decision.call_count == 0 + assert captured["skill"] is None + # Not an invocation, so nothing is stripped and no skill is reported. + assert captured["content"] == "/qa the first one" + assert "skill" not in result.meta + + +# --- telling the user a skill is running ---------------------------------- + + +def run_turn(skill_name: str | None) -> tuple[StubStreamManager, object]: + """One planner turn that answers immediately, on a recording stream.""" + planner = make_planner() + planner.model = "test-model" + planner.max_tool_calls = 1 + stream_manager = StubStreamManager() + + with patch.object(planner, "_call_api", return_value=FakeResponse("end_turn", [FakeText("done")])), \ + patch.object(planner, "_build_system_prompt", return_value="sys"): + result = planner.run( + content="check this", + workflow_yaml=None, + page=None, + history=[], + stream=False, + stream_manager=stream_manager, + skill=get_skill(skill_name) if skill_name else None, + ) + + return stream_manager, result + + +def test_a_skill_turn_opens_by_naming_the_skill() -> None: + stream_manager, _ = run_turn("qa") + + assert stream_manager.thinking == ["Running the /qa skill..."] + + +def test_a_skill_turn_settles_where_it_was_announced() -> None: + """Durable, and first: the invocation happens at the top of the turn, so + the settled line stays above the answer rather than trailing it.""" + _, result = run_turn("qa") + + assert result.response_segments[0] == {"type": "status", "content": "Ran the /qa skill"} + + +def test_a_turn_without_a_skill_opens_and_settles_as_before() -> None: + stream_manager, result = run_turn(None) + + assert stream_manager.thinking == [STATUS_NEW_WORKFLOW + STATUS_PLANNING] + assert [segment["type"] for segment in result.response_segments] == ["text"] + + +# --- the planner loading a skill itself ----------------------------------- + + +def test_the_load_skill_tool_offers_every_registered_skill() -> None: + assert LOAD_SKILL_TOOL in TOOL_DEFINITIONS + assert LOAD_SKILL_TOOL["input_schema"]["properties"]["name"]["enum"] == sorted(SKILLS) + for skill in SKILLS.values(): + assert f"- {skill.name}: {skill.description}" in LOAD_SKILL_TOOL["description"] + + +def test_loading_a_skill_returns_its_instructions() -> None: + planner = make_planner() + stream_manager = StubStreamManager() + + results = planner._execute_tool_blocks( + [FakeToolUse("load_skill", {"name": "qa"})], stream_manager, empty_usage(), [], + ) + + assert results[0]["content"] == get_skill("qa").body + assert stream_manager.thinking == ["Running the /qa skill..."] + assert stream_manager.statuses[0]["content"] == "Ran the /qa skill" + + +def test_loading_an_unknown_skill_names_the_real_ones() -> None: + planner = make_planner() + + result = planner._execute_tool( + FakeToolUse("load_skill", {"name": "sudo"}), StubStreamManager(), empty_usage(), [], + ) + + assert result.startswith("Error: Unknown skill") + assert "diagnose" in result + + +# --- skills written for a subagent ---------------------------------------- + + +def test_a_subagent_skill_is_neither_invoked_nor_loaded_by_the_planner() -> None: + assert "qa-code" in JOB_AGENT_SKILLS + assert "qa-code" not in SKILLS + assert "qa-code" not in LOAD_SKILL_TOOL["input_schema"]["properties"]["name"]["enum"] + with pytest.raises(ApolloError): + get_skill("qa-code") + + +def test_the_job_code_agent_tool_offers_every_job_agent_skill() -> None: + skill_param = CALL_JOB_CODE_AGENT_TOOL["input_schema"]["properties"]["skill"] + assert skill_param["enum"] == sorted(JOB_AGENT_SKILLS) + + +def test_a_named_subagent_skill_leads_the_job_agents_message() -> None: + with patch("job_chat.job_chat.main", return_value=job_chat_result()) as mock_main: + call_job_agent( + {"message": "leave the comments alone", "job_key": "fetch-patients", + "attachments": [], "skill": "qa-code"}, + workflow_yaml=WORKFLOW_YAML, + ) + + content = mock_main.call_args[0][0]["content"] + assert content.startswith(JOB_AGENT_SKILLS["qa-code"].as_block()) + # The planner's message is what this conversation adds, so it wins + assert "overrides these instructions" in content + assert content.endswith("leave the comments alone") + + +def test_an_unknown_subagent_skill_is_dropped_not_fatal() -> None: + with patch("job_chat.job_chat.main", return_value=job_chat_result()) as mock_main: + call_job_agent( + {"message": "fix it", "job_key": "fetch-patients", "attachments": [], "skill": "sudo"}, + workflow_yaml=WORKFLOW_YAML, + ) + + assert mock_main.call_args[0][0]["content"] == "fix it" + + +def test_the_job_agent_is_offered_its_skills_on_the_direct_route() -> None: + """A step question routed straight to the job agent can still use qa-code.""" + with patch("job_chat.job_chat.main", return_value=job_chat_result()) as mock_main: + make_router()._route_to_job_chat( + "is this step ready to go live?", WORKFLOW_YAML, "workflows/wf/fetch-patients", [], False, 5, + ) + + skills = mock_main.call_args[0][0]["skills"] + assert [s["name"] for s in skills] == sorted(JOB_AGENT_SKILLS) + # job_chat reads each skill's body directly + assert all(s["body"] == JOB_AGENT_SKILLS[s["name"]].body for s in skills) + + +def test_an_attached_skill_is_not_offered_again() -> None: + with patch("job_chat.job_chat.main", return_value=job_chat_result()) as mock_main: + call_job_agent( + {"message": "review", "job_key": "fetch-patients", "attachments": [], "skill": "qa-code"}, + workflow_yaml=WORKFLOW_YAML, + ) + + assert "qa-code" not in [s["name"] for s in mock_main.call_args[0][0]["skills"]] + + +# --- reporting the skills a turn used ------------------------------------- + + +def test_a_turn_reports_the_skill_the_planner_attached() -> None: + with patch("job_chat.job_chat.main", return_value=job_chat_result()): + result = call_job_agent( + {"message": "review", "job_key": "fetch-patients", "attachments": [], "skill": "qa-code"}, + workflow_yaml=WORKFLOW_YAML, + ) + + assert result["meta"]["skills"] == ["qa-code"] + + +def test_the_direct_route_reports_skills_the_job_agent_loaded() -> None: + loaded = {**job_chat_result(), "meta": {"skills": ["qa-code"]}} + with patch("job_chat.job_chat.main", return_value=loaded): + result = make_router()._route_to_job_chat( + "is this ready to go live?", WORKFLOW_YAML, "workflows/wf/fetch-patients", [], False, 5, + ) + + assert result.meta["skills"] == ["qa-code"] + + +def test_a_planner_turn_reports_the_skill_it_ran() -> None: + _, result = run_turn("qa") + assert result.meta["skills"] == ["qa"] + + _, result = run_turn(None) + assert "skills" not in result.meta diff --git a/services/global_chat/tools/tool_definitions.py b/services/global_chat/tools/tool_definitions.py index 5ed3f814..ceefab2b 100644 --- a/services/global_chat/tools/tool_definitions.py +++ b/services/global_chat/tools/tool_definitions.py @@ -4,6 +4,8 @@ These are Claude API tool schemas that define what tools are available. """ +from global_chat.skill_registry import JOB_AGENT_SKILLS, SKILLS + # Tool 1: Search documentation SEARCH_DOCUMENTATION_TOOL = { "name": "search_documentation", @@ -83,16 +85,45 @@ "cache_control": {"type": "ephemeral"} } +if JOB_AGENT_SKILLS: + CALL_JOB_CODE_AGENT_TOOL["input_schema"]["properties"]["skill"] = { + "type": "string", + "enum": sorted(JOB_AGENT_SKILLS), + "description": "Instructions for this kind of work, attached to your message word for word. Name one when a skill you are following tells you to, and put only what this conversation adds in `message`.", + } + # Tool 4: Inspect job code — shared with job_chat's subagent mode so both # agents explore the workflow with the exact same tool from yaml_utils import INSPECT_JOB_CODE_TOOL # noqa: E402 +# Tool 5: Load a skill — listed from the registry, so a new skill folder is +# offered to the model without editing this file +LOAD_SKILL_TOOL = { + "name": "load_skill", + "description": """Load a skill: instructions for a kind of task. When the task matches a skill below, load it first and follow its instructions. + +Available skills: +""" + "\n".join(f"- {skill.name}: {skill.description}" for skill in SKILLS.values()), + "input_schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": sorted(SKILLS), + "description": "The skill to load", + }, + }, + "required": ["name"], + }, +} + # Export all tool definitions TOOL_DEFINITIONS = [ SEARCH_DOCUMENTATION_TOOL, CALL_WORKFLOW_AGENT_TOOL, CALL_JOB_CODE_AGENT_TOOL, - INSPECT_JOB_CODE_TOOL + INSPECT_JOB_CODE_TOOL, + LOAD_SKILL_TOOL, ] diff --git a/services/job_chat/job_chat.py b/services/job_chat/job_chat.py index 9cc70017..e15671fc 100644 --- a/services/job_chat/job_chat.py +++ b/services/job_chat/job_chat.py @@ -19,7 +19,7 @@ import sentry_sdk from langfuse import observe, propagate_attributes, get_client as get_langfuse_client from langfuse_util import should_track, build_tags, build_generation_diff, mask_secrets -from util import ApolloError, create_logger, AdaptorSpecifier, add_page_prefix, APOLLO_VERSION +from util import ApolloError, create_logger, add_page_prefix, job_code_page, APOLLO_VERSION from yaml_utils import INSPECT_JOB_CODE_TOOL, inspect_job_code from .prompt import build_prompt, build_error_correction_prompt from .old_prompt import build_old_prompt @@ -122,11 +122,35 @@ ), } -# Max API rounds in one generate() call: enough for a couple of -# inspect_job_code round-trips plus the final answer. +# Max API rounds in one generate() call: enough for a skill load and an +# inspect_job_code round-trip plus the final answer. _MAX_TOOL_ROUNDS = 4 +def _load_skill_tool(skills: List[Dict[str, str]]) -> Dict[str, Any]: + """The skills the caller gave this agent, offered the way the planner's are.""" + listing = "\n".join(f"- {s['name']}: {s['description']}" for s in skills) + return { + "name": "load_skill", + "description": ( + "Load a skill: instructions for a kind of task, holding OpenFn practice " + "you won't otherwise have. When the task matches a skill below, load it " + f"first and follow its instructions.\n\nAvailable skills:\n{listing}" + ), + "input_schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": sorted(s["name"] for s in skills), + "description": "The skill to load", + }, + }, + "required": ["name"], + }, + } + + # Helper function for page navigation def extract_page_prefix_from_last_turn(history: List[Dict[str, str]]) -> Optional[str]: """Extract page prefix from last user message if present.""" @@ -165,6 +189,8 @@ class Payload: # inspect_job_code tool. workflow_yaml: Optional[str] = None subagent: Optional[bool] = False + # Skills the caller makes available to this agent: name, description, body + skills: Optional[List[Dict[str, str]]] = None @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Payload": @@ -186,6 +212,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "Payload": metrics_opt_in=data.get("metrics_opt_in"), workflow_yaml=data.get("workflow_yaml"), subagent=data.get("subagent", False), + skills=data.get("skills"), ) @@ -206,6 +233,7 @@ class ChatResponse: diff: Optional[Dict[str, Any]] = None # Subagent mode only: reason the request was handed back to the caller handover: Optional[str] = None + skills_loaded: Optional[List[str]] = None class AnthropicClient: def __init__(self, config: Optional[ChatConfig] = None): @@ -243,6 +271,7 @@ def generate( workflow_yaml: Optional[str] = None, subagent: Optional[bool] = False, stream_manager: Optional[StreamManager] = None, + skills: Optional[List[Dict[str, str]]] = None, ) -> ChatResponse: """ Generate a response using the Claude API with optional streaming. @@ -275,7 +304,8 @@ def generate( download_adaptor_docs=download_adaptor_docs, refresh_rag=refresh_rag, workflow_yaml=workflow_yaml, - subagent=subagent + subagent=subagent, + has_skills=bool(skills), ) else: @@ -300,11 +330,16 @@ def generate( tools.append(_EDIT_WORKFLOW_TOOL) if workflow_yaml: tools.append(_INSPECT_JOB_CODE_TOOL) + if skills: + tools.append(_load_skill_tool(skills)) + skill_bodies = {s["name"]: s["body"] for s in skills or []} + skills_loaded = [] tool_kwargs = {"tools": tools, "tool_choice": {"type": "auto"}} if tools else {} - # Without the subagent tools this loop runs exactly once: edit_job - # is terminal (its input IS the output), so only inspect_job_code - # triggers another round and only handover exits early. + # Without the subagent tools or skills this loop runs exactly once: + # edit_job is terminal (its input IS the output), so only + # inspect_job_code and load_skill trigger another round and only + # handover exits early. messages = prompt handover_reason = None text_parts = [] @@ -394,11 +429,11 @@ def generate( handover_reason = (handover_block.input or {}).get("goal") or "handover requested" break - inspect_blocks = [b for b in tool_uses if b.name == "inspect_job_code"] - if not inspect_blocks or round_index == _MAX_TOOL_ROUNDS - 1: + reading_blocks = [b for b in tool_uses if b.name in ("inspect_job_code", "load_skill")] + if not reading_blocks or round_index == _MAX_TOOL_ROUNDS - 1: break - # Answer the inspect calls and let the model continue. An + # Answer the reading calls and let the model continue. An # edit_job call made in the same round is deferred: the # model must re-issue it with its final answer. stream_manager.send_thinking(STATUS_REVIEWING_CODE) @@ -408,6 +443,14 @@ def generate( job_keys = (block.input or {}).get("job_keys") or [] logger.info("job_chat inspect_job_code: reading %s", job_keys) result_text = inspect_job_code(workflow_yaml, job_keys) + elif block.name == "load_skill": + skill_name = (block.input or {}).get("name") + logger.info("job_chat load_skill: %s", skill_name) + if skill_name in skill_bodies and skill_name not in skills_loaded: + skills_loaded.append(skill_name) + result_text = skill_bodies.get( + skill_name, f"Error: Unknown skill. Available skills: {sorted(skill_bodies)}", + ) else: result_text = ( "Not applied. Finish inspecting, then write your final reply " @@ -529,7 +572,8 @@ def generate( history=updated_history, usage=usage, rag=retrieved_knowledge, - diff=diff + diff=diff, + skills_loaded=skills_loaded, ) def process_stream_event( @@ -766,21 +810,7 @@ def main(data_dict: dict) -> dict: if data.context is None: data.context = {} - # Construct current_page from context - page_name = data.context.get("page_name") - adaptor_string = data.context.get("adaptor") - - current_page = { - "type": "job_code", - "name": page_name - } - - if adaptor_string: - try: - adaptor = AdaptorSpecifier(adaptor_string) - current_page["adaptor"] = f"{adaptor.short_name}@{adaptor.version}" - except Exception as e: - logger.warning(f"Failed to parse adaptor string '{adaptor_string}': {e}") + current_page = job_code_page(data.context.get("page_name"), data.context.get("adaptor")) # Extract rag_data from meta if present rag_data = input_meta.get("rag") if isinstance(input_meta, dict) else None @@ -814,6 +844,7 @@ def main(data_dict: dict) -> dict: # In-process callers (global_chat) may inject a shared stream # manager so a handed-over request continues the same stream stream_manager=data_dict.get("_stream_manager"), + skills=data.skills, ) # Tag the trace when code was generated, so we can filter for it. @@ -849,6 +880,9 @@ def main(data_dict: dict) -> dict: if result.handover: response_dict["handover"] = result.handover + if result.skills_loaded: + response_dict["meta"]["skills"] = result.skills_loaded + return response_dict except ApolloError: diff --git a/services/job_chat/prompt.py b/services/job_chat/prompt.py index 115b855d..83ece793 100644 --- a/services/job_chat/prompt.py +++ b/services/job_chat/prompt.py @@ -460,7 +460,7 @@ def format_search_results(search_results): @observe(name="job_chat_build_prompt") def build_prompt(content, history, context, rag=None, api_key=None, stream_manager=None, download_adaptor_docs=True, refresh_rag=False, - workflow_yaml=None, subagent=False): + workflow_yaml=None, subagent=False, has_skills=False): retrieved_knowledge = { "search_results": [], "search_results_sections": [], @@ -510,6 +510,8 @@ def build_prompt(content, history, context, rag=None, api_key=None, stream_manag " If it needs changes beyond this step's code, call `edit_workflow` first;" " to merely read another step (to answer, or to edit this one), use `inspect_job_code`." ) + if has_skills: + reminder += " If one of your skills fits the task, load it first." prompt.append({ "role": "user", "content": f"{content}\n\n{reminder}", diff --git a/services/job_chat/tests/unit/test_skills.py b/services/job_chat/tests/unit/test_skills.py new file mode 100644 index 00000000..fc0119b4 --- /dev/null +++ b/services/job_chat/tests/unit/test_skills.py @@ -0,0 +1,74 @@ +"""Unit tests for skills handed to job_chat by its caller. + +The caller passes the skills written for this agent; job_chat offers them +through a load_skill tool, and loading one returns its instructions and lets +the model carry on, the same way reading another step does. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from job_chat.job_chat import AnthropicClient, ChatConfig + +SKILLS = [{"name": "qa-code", "description": "Review one step", "body": "Check the loops."}] + + +def tool_use(name: str, tool_input: dict) -> SimpleNamespace: + return SimpleNamespace(type="tool_use", id=f"tu_{name}", name=name, input=tool_input) + + +def text(value: str) -> SimpleNamespace: + return SimpleNamespace(type="text", text=value) + + +def message(*blocks: SimpleNamespace) -> SimpleNamespace: + usage = MagicMock() + usage.model_dump.return_value = {"input_tokens": 1, "output_tokens": 1} + usage.cache_creation_input_tokens = 0 + usage.cache_read_input_tokens = 0 + return SimpleNamespace(content=list(blocks), usage=usage, stop_reason="end_turn") + + +def run(responses: list, skills: list | None) -> MagicMock: + with patch("job_chat.job_chat.Anthropic"): + client = AnthropicClient(ChatConfig(api_key="test")) + client.client.messages.create.side_effect = responses + with patch("job_chat.job_chat.build_prompt", return_value=("system", [{"role": "user", "content": "hi"}], {})): + client.generate(content="is this ready?", suggest_code=True, subagent=True, skills=skills) + return client.client.messages.create + + +def test_given_skills_are_offered_as_a_load_skill_tool() -> None: + create = run([message(text("Looks fine."))], SKILLS) + + tools = {t["name"]: t for t in create.call_args.kwargs["tools"]} + assert tools["load_skill"]["input_schema"]["properties"]["name"]["enum"] == ["qa-code"] + assert "- qa-code: Review one step" in tools["load_skill"]["description"] + + +def test_without_skills_there_is_no_load_skill_tool() -> None: + create = run([message(text("Looks fine."))], None) + + assert "load_skill" not in [t["name"] for t in create.call_args.kwargs["tools"]] + + +def test_loading_a_skill_returns_its_instructions_and_continues() -> None: + create = run( + [message(tool_use("load_skill", {"name": "qa-code"})), message(text("Reviewed."))], + SKILLS, + ) + + tool_result = create.call_args.kwargs["messages"][-1]["content"][0] + assert tool_result["content"] == "Check the loops." + + +def test_a_loaded_skill_is_reported() -> None: + with patch("job_chat.job_chat.Anthropic"): + client = AnthropicClient(ChatConfig(api_key="test")) + client.client.messages.create.side_effect = [ + message(tool_use("load_skill", {"name": "qa-code"})), message(text("Reviewed.")), + ] + with patch("job_chat.job_chat.build_prompt", return_value=("system", [{"role": "user", "content": "hi"}], {})): + result = client.generate(content="is this ready?", suggest_code=True, subagent=True, skills=SKILLS) + + assert result.skills_loaded == ["qa-code"] diff --git a/services/testing/README.md b/services/testing/README.md index fb1d9d4b..8a23b7ce 100644 --- a/services/testing/README.md +++ b/services/testing/README.md @@ -88,6 +88,9 @@ subfolders). Paths are under `services//tests/acceptance/`. Pass `--no-judge` to skip judging: the service is still called and its output captured, but nothing is graded and every spec passes. +- `spec_context.py` — prints a spec's context (`# settings`) as payload JSON, + so the `simulate-users` skill can reuse spec setups without copying them: + `PYTHONPATH=services poetry run python -m testing.spec_context `. - `apollo_client.py` — `ApolloClient` for dispatching to a chat service. Currently a subprocess-based stub; the integration tier will replace its internals with a real HTTP client (same `.call()` signature, no test changes). diff --git a/services/testing/spec_collector.py b/services/testing/spec_collector.py index 9f7fd2be..df737e93 100644 --- a/services/testing/spec_collector.py +++ b/services/testing/spec_collector.py @@ -361,8 +361,12 @@ def _capture_response_text( if not isinstance(text, str) or not text.strip(): return None - agent_path = _format_agent_path(response.get("meta", {})) - body = f"agents: {agent_path}\n\n{text}" if agent_path else text + meta = response.get("meta") or {} + agent_path = _format_agent_path(meta) + header = [f"agents: {agent_path}"] if agent_path else [] + if meta.get("skills"): + header.append(f"skills: {', '.join(meta['skills'])}") + body = "\n".join(header) + f"\n\n{text}" if header else text output_dir.mkdir(parents=True, exist_ok=True) suffix = f"__run-{run_index}" if runs > 1 else "" diff --git a/services/testing/spec_context.py b/services/testing/spec_context.py new file mode 100644 index 00000000..40dda60c --- /dev/null +++ b/services/testing/spec_context.py @@ -0,0 +1,23 @@ +"""Print an acceptance spec's context (yaml, page, attachments, ...) as payload JSON. + +Lets the simulate-users skill reuse a spec's setup as stock context without +copying it. The spec's own turn, history and session meta are left out. + +Usage: PYTHONPATH=services poetry run python -m testing.spec_context +""" + +import json +import sys +from pathlib import Path + +from testing.spec_parser import parse_spec + + +def main() -> None: + settings = parse_spec(Path(sys.argv[1])).settings + settings.pop("meta", None) + json.dump(settings, sys.stdout, indent=2) + + +if __name__ == "__main__": + main() diff --git a/services/util.py b/services/util.py index 1be26a92..88040322 100644 --- a/services/util.py +++ b/services/util.py @@ -313,6 +313,18 @@ def add_page_prefix(content: str, page: dict | None) -> str: return f"{prefix} {content}" +def job_code_page(name: str | None, adaptor: str | None) -> dict: + """The page dict for a step's code page, shared so every route records it alike.""" + page = {"type": "job_code", "name": name} + if adaptor: + try: + spec = AdaptorSpecifier(adaptor) + page["adaptor"] = f"{spec.short_name}@{spec.version}" + except Exception as e: + create_logger("util").warning(f"Failed to parse adaptor string '{adaptor}': {e}") + return page + + def is_docsite_collection(name: str) -> bool: """ True for docsite collection (namespace) names created by embed_docsite: From 112fdd256af68816a3f33d43a055247541cc6a8c Mon Sep 17 00:00:00 2001 From: "Elias W. BA" Date: Fri, 23 Oct 2026 12:56:50 +0000 Subject: [PATCH 26/26] Release 3.5.0 --- .changeset/planner-page-prefix.md | 5 ----- .changeset/skills-as-slash-commands.md | 5 ----- .changeset/warm-pages-cache.md | 5 ----- CHANGELOG.md | 17 +++++++++++++++++ package.json | 2 +- 5 files changed, 18 insertions(+), 16 deletions(-) delete mode 100644 .changeset/planner-page-prefix.md delete mode 100644 .changeset/skills-as-slash-commands.md delete mode 100644 .changeset/warm-pages-cache.md diff --git a/.changeset/planner-page-prefix.md b/.changeset/planner-page-prefix.md deleted file mode 100644 index b513c435..00000000 --- a/.changeset/planner-page-prefix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apollo": patch ---- - -global_chat: planner turns now record the `[pg:...]` page prefix in the returned history, like the direct routes, so every route returns history in the same shape diff --git a/.changeset/skills-as-slash-commands.md b/.changeset/skills-as-slash-commands.md deleted file mode 100644 index e78136a4..00000000 --- a/.changeset/skills-as-slash-commands.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apollo": minor ---- - -global_chat: accept a `skill` field naming a standard skill the user invoked by slash command. `/design`, `/diagnose` and `/qa` ship with the agent; an invoked skill skips the router and leads the planner's turn. Skill instructions are kept in the returned history, so the client sends `skill` only on the turn that invokes it diff --git a/.changeset/warm-pages-cache.md b/.changeset/warm-pages-cache.md deleted file mode 100644 index 76639dc0..00000000 --- a/.changeset/warm-pages-cache.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apollo": patch ---- - -global_chat: cache the planner's history across tool-loop rounds diff --git a/CHANGELOG.md b/CHANGELOG.md index 59606ccf..261c4c83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # apollo +## 3.5.0 + +### Minor Changes + +- e08ba09: global_chat: accept a `skill` field naming a standard skill the user + invoked by slash command. `/design`, `/diagnose` and `/qa` ship with the + agent; an invoked skill skips the router and leads the planner's turn. Skill + instructions are kept in the returned history, so the client sends `skill` + only on the turn that invokes it + +### Patch Changes + +- e08ba09: global_chat: planner turns now record the `[pg:...]` page prefix in + the returned history, like the direct routes, so every route returns history + in the same shape +- 299580e: global_chat: cache the planner's history across tool-loop rounds + ## 3.4.0 ### Minor Changes diff --git a/package.json b/package.json index d5441f81..d0a5841a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "apollo", "module": "platform/index.ts", - "version": "3.4.0", + "version": "3.5.0", "type": "module", "scripts": { "start": "NODE_ENV=production bun platform/src/index.ts",