diff --git a/.changeset/olive-moons-search.md b/.changeset/olive-moons-search.md deleted file mode 100644 index 07a4f319..00000000 --- a/.changeset/olive-moons-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"apollo": minor ---- - -global_chat: opt-in web search and fetch for the planner 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..04df1fd8 --- /dev/null +++ b/.claude/skills/draft-assistant-skill/SKILL.md @@ -0,0 +1,175 @@ +--- +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. +- 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/.env.example b/.env.example index 8f27f4bb..5e945f07 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) +# Turn this on to use the fork server mechanism +# APOLLO_FORK_SERVER=true + +# macOS only: required when APOLLO_FORK_SERVER=true +# OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES + 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/CHANGELOG.md b/CHANGELOG.md index 8f28238e..59606ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # apollo +## 3.4.0 + +### Minor Changes + +- 02eaf61: global_chat: opt-in web search and fetch for the planner + +### Patch Changes + +- 0347252: Update dependencies, including the Anthropic Python SDK to 1.x, the + OpenAI SDK to 3.x and pandas to 3.x +- 8ff4d37: fork python processes to reduce startup time + ## 3.3.1 ### Patch Changes 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/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=="], diff --git a/package.json b/package.json index 25814723..d5441f81 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "apollo", "module": "platform/index.ts", - "version": "3.3.1", + "version": "3.4.0", "type": "module", "scripts": { "start": "NODE_ENV=production bun platform/src/index.ts", @@ -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": { diff --git a/platform/src/bridge.fork.ts b/platform/src/bridge.fork.ts new file mode 100644 index 00000000..6427d7be --- /dev/null +++ b/platform/src/bridge.fork.ts @@ -0,0 +1,183 @@ +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 { LOG_LINE } from "./util/log-line"; +import pkg from "../../package.json"; + +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) => { + if (line.startsWith("READY")) { + clearTimeout(timer); + resolve({ proc, socketPath }); + } + }); + proc.on("error", fail); + proc.on("exit", (code, sig) => { + 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})`)); + }); + }); +} + +// 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 240e9ade..bd79ad6e 100644 --- a/platform/src/bridge.ts +++ b/platform/src/bridge.ts @@ -12,10 +12,8 @@ import { subprocessSpawnFailed, } from "./util/errors"; import pkg from "../../package.json"; - -// 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 { USE_FORK_SERVER, runForked } from "./bridge.fork"; +import { LOG_LINE } from "./util/log-line"; /** Run a python script @@ -61,6 +59,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/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):/; diff --git a/poetry.lock b/poetry.lock index 6ea8d960..2a210221 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.4.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.11.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.11.0-py3-none-any.whl", hash = "sha256:52f97b2c485cca7ac66058374f5073a3febb7e6849b16989c145d602a3efee21"}, + {file = "anthropic-1.11.0.tar.gz", hash = "sha256:3906fabac7ad7b5b46c6186040398fc7826885c77ce34e4dd7849de16fc8d0f8"}, ] [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)"] @@ -462,18 +461,6 @@ files = [ {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] -[[package]] -name = "distro" -version = "1.9.0" -description = "Distro - an OS platform information API" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, - {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, -] - [[package]] name = "docstring-parser" version = "0.18.0" @@ -757,6 +744,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 +804,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 +860,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]] @@ -1046,14 +1093,14 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" [[package]] name = "langchain-core" -version = "1.6.4" +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.4-py3-none-any.whl", hash = "sha256:2f940c83f787676ad4dc651d49d053c7ec34bda5a6afcd224da2863ff53a0b84"}, - {file = "langchain_core-1.6.4.tar.gz", hash = "sha256:c1caae770d991bb6f2376bad791970bf77bb29abd1f15823fa9ea7dbac080fa0"}, + {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] @@ -1070,22 +1117,25 @@ uuid-utils = ">=0.12.0,<1.0" [[package]] name = "langchain-openai" -version = "1.6.3" +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.3-py3-none-any.whl", hash = "sha256:c4529e694512e8058219998574b58aa8307d8cd3a13f4b1fc682dd31883f63ef"}, - {file = "langchain_openai-1.6.3.tar.gz", hash = "sha256:906953395f50a896159b7bac83a5de7ef627160056b69946b22b8cad8f39bbfc"}, + {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" @@ -1124,29 +1174,29 @@ 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" -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] @@ -1425,116 +1475,153 @@ files = [ [[package]] name = "openai" -version = "2.48.0" +version = "3.25.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.25.0-py3-none-any.whl", hash = "sha256:31ae9e63134f0bdb4440feca994ceeb0feeeaec88197e0fc011fcc49ff491e70"}, + {file = "openai-3.25.0.tar.gz", hash = "sha256:3dabc85a408ee7196aa03dfbe68b5177530aa1543282c2ed692be2e531e31e94"}, ] [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.dev0 || >=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)"] [[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 +1635,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 +1667,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]] @@ -1729,99 +1816,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" @@ -2367,31 +2457,19 @@ 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] 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" @@ -2638,42 +2716,42 @@ requests = ">=2.0.1,<3.0.0" [[package]] name = "ruff" -version = "0.16.8" +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.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.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]] 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] @@ -3054,6 +3132,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" @@ -3088,6 +3179,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"}, @@ -3095,14 +3187,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] @@ -3759,5 +3851,5 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" -python-versions = "3.11.*" -content-hash = "8886c2739952046692045906293b67f982abb2f848864097d8f667304f50bf8f" +python-versions = ">=3.11,<3.12" +content-hash = "2add86d883acf08254e340046c1fa784e73c1fa261879c79195d43ea8183c1e0" diff --git a/pyproject.toml b/pyproject.toml index b80f6797..9eac9a5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,10 @@ package-mode = false requires-poetry = ">=2.3.2" [tool.poetry.dependencies] -python = "3.11.*" -openai = "^2.48" -python-dotenv = "^1.2.3" -anthropic = "^0.119.0" +python = ">=3.11,<3.12" +openai = "^3.20" +python-dotenv = "^1.2.4" +anthropic = "^1.9.0" langchain-pinecone = "^0.2.13" langchain-core = "^1.6" @@ -21,12 +21,12 @@ 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" -pandas = "^2.2" +langfuse = "^4.15.6" +opentelemetry-instrumentation-anthropic = "^0.62.4" +opentelemetry-instrumentation-threading = "0.66b0" +pandas = "^3.0" pgvector = "^0.5.0" [tool.poetry.group.dev] @@ -34,7 +34,7 @@ optional = false [tool.poetry.group.dev.dependencies] pytest = "^9.1.1" -ruff = "^0.16.8" +ruff = "^0.16.10" [build-system] requires = ["poetry-core"] diff --git a/services/entry.fork.py b/services/entry.fork.py new file mode 100644 index 00000000..c83cbf71 --- /dev/null +++ b/services/entry.fork.py @@ -0,0 +1,137 @@ +"""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 +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). +# 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.""" + 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()] + 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 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) + sys.stdout = os.fdopen(1, "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 + + sentry_sdk.set_tag("service", req["service"]) # entry.main() tags it; call() doesn't + 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: + 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) + + +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) 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 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/config.yaml b/services/global_chat/config.yaml index ca394f25..47ab6185 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/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 aa1fd95e..d9550260 100644 --- a/services/global_chat/planner.py +++ b/services/global_chat/planner.py @@ -7,7 +7,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, BadRequestError import sentry_sdk @@ -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 @@ -76,7 +77,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) @@ -160,7 +161,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. @@ -255,6 +256,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}") @@ -270,6 +273,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. @@ -285,6 +289,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 @@ -292,10 +298,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 @@ -303,9 +305,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) @@ -437,10 +443,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 @@ -525,9 +531,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 = { @@ -538,6 +550,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" @@ -555,16 +575,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: @@ -642,7 +679,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 @@ -763,6 +800,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}" @@ -981,6 +1026,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: @@ -1013,6 +1061,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 987da279..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 @@ -70,7 +71,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}") @@ -85,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: """ @@ -97,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 @@ -121,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( @@ -181,7 +191,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} @@ -384,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, } @@ -409,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 @@ -472,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, @@ -479,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 @@ -499,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..23f3a994 --- /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. Workflow changes, + such as triggers, edges or adaptor versions, go through the workflow agent. + +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, so propose fixes +rather than prescribe them: +- 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 for changes the user has agreed to, +give each step the ones listed for it 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_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..4e9345a0 --- /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. Workflow changes, + such as triggers, edges or adaptor versions, go through the workflow agent. + +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, so propose fixes +rather than prescribe them: +- 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 for changes the user has agreed to, +give each step the ones listed for it 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 70fb8766..dd71583e 100644 --- a/services/global_chat/tests/unit/test_planner.py +++ b/services/global_chat/tests/unit/test_planner.py @@ -4,7 +4,7 @@ from unittest.mock import patch import anthropic -import httpx +import httpx2 import pytest import yaml from anthropic import BadRequestError @@ -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, @@ -571,8 +576,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, @@ -629,11 +634,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": { @@ -656,11 +661,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": { @@ -686,11 +691,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"}}, ), @@ -707,11 +712,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"}}, ), @@ -726,13 +731,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}}, ), @@ -768,11 +773,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", ), ) @@ -784,11 +789,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": { @@ -805,8 +810,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) @@ -817,9 +822,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 @@ -881,11 +886,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", @@ -899,11 +904,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={}, ), ) @@ -916,13 +921,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)], ), @@ -947,17 +952,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"}}, ), @@ -974,7 +979,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 ( @@ -1002,7 +1007,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" ) @@ -1023,7 +1028,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." @@ -1454,9 +1459,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: 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 919040ed..e15671fc 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, @@ -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 = [] @@ -370,7 +405,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) @@ -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/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/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 bb81cc42..8a23b7ce 100644 --- a/services/testing/README.md +++ b/services/testing/README.md @@ -30,6 +30,24 @@ Two rules of thumb: - **A guard you have not seen fail is not a guard.** Break the code, watch the test go red, put it back. +## Acceptance spec folders + +Run one folder's own specs with +`poetry run pytest /*.md -s` (the bare folder also collects its +subfolders). Paths are under `services//tests/acceptance/`. + +| Folder | What it tests | +|---|---| +| `workflow_chat/` | The workflow agent alone: generating and editing workflow YAML | +| `job_chat/` | The job code agent alone: code for a single step | +| `job_chat/quality_issues/` | Whether generated code avoids known code-quality mistakes | +| `job_chat/bugs/` | Repros of real job_chat failures | +| `global_chat/job_code/` | Questions and code changes about one step, through the router | +| `global_chat/multi_step_changes/` | Edits to an existing workflow that the planner splits across agents | +| `global_chat/one_shot_workflows/` | A whole workflow with job code from one request | +| `global_chat/gold_workflows/` | Real-world workflow patterns, each phrased vaguely and verbosely | +| `global_chat/bugs/` | Repros of real global_chat failures | + ## Modules - `yaml_assertions.py` — pure-function YAML structural assertions, safe for @@ -62,6 +80,17 @@ Two rules of thumb: `-E